diff --git a/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.ts b/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.ts
index d0b8222..6675de6 100644
--- a/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.ts
+++ b/Development/client/src/app/entities/vehicle/vehicle-edit/vehicle-edit.component.ts
@@ -1,115 +1,59 @@
-import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
+import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
-import { SelectItem } from 'primeng/api';
import { Vehicle } from '../../models/vehicle.model';
import * as vehicleActions from '../../actions/vehicle.actions';
+
import { StringUtils } from '@app/shared/utils';
import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component';
-import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component';
-import { globals, VehType, vehTypes, SystemTypes, SourceSystem, OperationalStatus, Labels } from '@app/shared/global';
+import { globals, VehType, vehTypes } from '@app/shared/global';
+import { SelectItem } from 'primeng/api';
import { BaseComp } from '@app/shared/base/base.component';
import { selectLimit } from '@app/reducers';
import { Limit } from '@app/domain/models/subscription.model';
import { SubKeys, SubType } from '@app/profile/common';
-import { PartnerIntegrationData, VehiclePartnerIntegrationComponent } from '../vehicle-partner-integration/vehicle-partner-integration.component';
-
-// ============================================================================
-// COMPONENT
-// ============================================================================
@Component({
selector: 'agm-vehicle-edit',
templateUrl: './vehicle-edit.component.html',
- styleUrls: ['./vehicle-edit.component.css']
+ styles: []
})
export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewInit, OnDestroy {
-
- // ============================================================================
- // CONSTANTS & READONLY PROPERTIES
- // ============================================================================
-
readonly globals = globals;
- readonly SourceSystem = SourceSystem;
- readonly Labels = Labels;
-
- // ============================================================================
- // CORE VEHICLE PROPERTIES
- // ============================================================================
selectedItem: Vehicle;
orgUnitId: string;
-
- // Core vehicle form options
acTypes: SelectItem[];
acColors: SelectItem[];
- // Partner integration state (managed by child component)
- private partnerData: PartnerIntegrationData | null = null;
- partnerValidationState: boolean = true; // Default to valid for basic aircraft
-
- // Return message handling from account-edit
- connectionTestMessage: string | null = null;
- connectionTestSuccess: boolean | null = null;
- pendingAuthenticationSuccess: boolean = false; // Flag to update partner auth state after ViewInit
-
- // ============================================================================
- // VIEW CHILDREN & UI STATE
- // ============================================================================
-
@ViewChild('vehicleName') vehicleName: ElementRef;
@ViewChild('account') accEditor: AccountEditorComponent;
- @ViewChild('partnerIntegration') partnerIntegration: VehiclePartnerIntegrationComponent;
- @ViewChild('tailNumberConstraint') tailNumberConstraint: ConstraintMessageComponent;
+
hasTracking: boolean;
- // ============================================================================
- // VEHICLE MANAGEMENT PROPERTIES
- // ============================================================================
-
private _vehicle: Vehicle;
- private _isNew: boolean;
-
- get vehicle(): Vehicle {
- return this._vehicle;
- }
-
+ get vehicle(): Vehicle { return this._vehicle; }
set vehicle(vehicle: Vehicle) {
this._vehicle = vehicle;
- this.selectedItem = Object.assign({}, vehicle);
+ this.selectedItem = Object.assign({}, vehicle); // create a clone object to work on the editor
- // For new vehicles, ensure active defaults to true
- // Check vehicle._id directly since _isNew is set later
- if (vehicle._id === '0') {
- // For new vehicles, active should always default to true
- this.selectedItem.active = true;
- }
-
- if (!this.isNew && this.selectedItem.unitId) {
+ if (!this.isNew && this.selectedItem.unitId)
this.orgUnitId = this.selectedItem.unitId;
- }
}
+ private _isNew: boolean;
get isNew(): boolean {
return this._isNew;
}
get user() {
- return this.selectedItem.username ?
- { username: this.selectedItem.username, password: this.selectedItem.password } :
- null;
+ return this.selectedItem.username ? ({ username: this.selectedItem.username, password: this.selectedItem.password }) : null;
}
- // ============================================================================
- // CONSTRUCTOR
- // ============================================================================
-
constructor(
private readonly route: ActivatedRoute,
- private readonly cdr: ChangeDetectorRef
) {
super();
-
this.acTypes = [
{ label: vehTypes[VehType.FIXEDSWING], value: VehType.FIXEDSWING },
{ label: vehTypes[VehType.HELICOPTER], value: VehType.HELICOPTER }
@@ -121,84 +65,36 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
{ label: globals.lime, value: 'lime' },
{ label: globals.yellow, value: 'yellow' },
{ label: globals.orange, value: 'orange' },
- { label: globals.purple, value: 'purple' }
+ { label: globals.purple, value: 'purple' },
];
}
- // ============================================================================
- // LIFECYCLE METHODS
- // ============================================================================
-
ngOnInit() {
- // Handle query parameters for return navigation messages
- this.sub$ = this.route.queryParams.subscribe(params => {
- if (params['connectionTestResult']) {
- this.connectionTestSuccess = params['connectionTestResult'] === 'success';
- this.connectionTestMessage = params['message'];
-
- if (this.connectionTestSuccess) {
- console.log('Account authentication successful:', this.connectionTestMessage);
- // Set flag to update partner auth state after ViewInit
- this.pendingAuthenticationSuccess = true;
- // Optionally show success message to user
- if (this.msgSvc) {
- this.msgSvc.addSuccessMsg(this.connectionTestMessage);
- }
- } else {
- console.error('Account authentication failed:', this.connectionTestMessage);
- // Show error message to user
- if (this.msgSvc) {
- this.msgSvc.addFailedMsg(this.connectionTestMessage);
- }
+ this.sub$ = this.route.data
+ .subscribe((data) => {
+ const vehicle = data[0] as Vehicle || null;
+ if (vehicle) {
+ this.vehicle = vehicle;
+ this._isNew = (this.vehicle._id === '0');
}
+ });
+ this.sub$.add(this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS])
+ .subscribe((action) => {
+ this.store.dispatch(new vehicleActions.Select(action['payload']));
+ this.goBack();
+ }));
- // Clear query parameters to prevent message from showing again
- // Wait longer (15 seconds) to allow partner aircraft API call to complete
- // Navigation during API call can cancel the HTTP request
- setTimeout(() => {
- this.router.navigate([], {
- relativeTo: this.route,
- queryParams: {},
- replaceUrl: true
- });
- }, 15000); // Wait 15 seconds for API call to complete
- }
- });
-
- // Route data subscription
- this.sub$.add(this.route.data.subscribe((data) => {
- const vehicle = data[0] as Vehicle || null;
- if (vehicle) {
- this.vehicle = vehicle;
- this._isNew = (this.vehicle._id === '0');
- }
- }));
-
- // Vehicle actions subscription
- this.sub$.add(
- this.appActions.ofTypes([vehicleActions.CREATE_SUCCESS, vehicleActions.UPDATE_SUCCESS])
- .subscribe((action) => {
- const savedVehicle = action['payload'];
- this.store.dispatch(new vehicleActions.Select(savedVehicle));
- this.goBack(savedVehicle);
- })
- );
-
- // Tracking subscription
- this.sub$.add(
- this.store.select(selectLimit(SubType.ADDON))
- .subscribe((addon) => {
- const tracking: Limit = addon?.[SubKeys.TRACKING];
- this.hasTracking = tracking?.airCraft?.numOfVehicle > 0;
- })
- );
+ this.sub$.add(this.store.select(selectLimit(SubType.ADDON))
+ .subscribe((addon) => {
+ const tracking: Limit = addon?.[SubKeys.TRACKING];
+ this.hasTracking = tracking?.airCraft?.numOfVehicle > 0;
+ }));
}
ngAfterViewInit(): void {
- // Auto-focus vehicle name field for new vehicles
const timer = setInterval(() => {
if (this.selectedItem && StringUtils.isEmpty(this.selectedItem.name)) {
- if (this.vehicleName && this.vehicleName.nativeElement) {
+ if (this.vehicleName.nativeElement) {
this.vehicleName.nativeElement.focus();
clearInterval(timer);
}
@@ -206,153 +102,9 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
clearInterval(timer);
}
}, 500);
- setTimeout(() => clearInterval(timer), 1500);
-
- // Handle pending authentication success from query params
- if (this.pendingAuthenticationSuccess && this.partnerIntegration) {
- console.log('Applying pending authentication success to partner integration');
- this.partnerIntegration.updateAuthenticationSuccess();
- this.pendingAuthenticationSuccess = false;
- }
-
- // Check for stored form data from Account Does Not Exist flow and restore it
- if (this.partnerIntegration) {
- const storedFormData = this.partnerIntegration.getStoredFormData();
- if (storedFormData) {
- console.log('Restoring vehicle form data after account creation:', storedFormData);
- this.restoreFormData(storedFormData);
- }
- }
+ setTimeout(() => { clearInterval(timer); }, 1500);
}
- ngOnDestroy() {
- super.ngOnDestroy();
- }
-
- // ============================================================================
- // PARTNER INTEGRATION EVENT HANDLERS
- // ============================================================================
-
- onPartnerDataChange(partnerData: PartnerIntegrationData): void {
- this.partnerData = partnerData;
-
- // Update vehicle tail number if partner aircraft is selected
- if (partnerData.tailNumber) {
- this.selectedItem.tailNumber = partnerData.tailNumber;
- }
- }
-
- onPartnerValidationStateChange(isValid: boolean): void {
- this.partnerValidationState = isValid;
- }
-
- /**
- * Get constraint message details when partner validation is invalid
- */
- getPartnerConstraintDetails(): { title: string; message: string } | null {
- // Only show partner-specific constraint messages
- // Basic form validation (like aircraft name) is handled by Angular forms
-
- // Check partner validation state
- if (!this.partnerValidationState && this.partnerIntegration) {
- const integration = this.partnerIntegration;
-
- // If partner system selected, check integration requirements
- if (integration.isPartnerSystemSelected) {
- // If partner validation failed
- if (!integration.partnerValidation.accountExists || !integration.partnerValidation.authenticationValid) {
- return null; // These are handled by the partner integration component
- }
-
- // If no aircraft selected
- if (!integration.selectedPartnerAircraft) {
- return {
- title: this.Labels.AIRCRAFT_SELECTION_REQUIRED_TITLE,
- message: this.Labels.AIRCRAFT_SELECTION_REQUIRED_MESSAGE
- };
- }
-
- // If Satloc partner and no system type selected
- if (integration.isSatlocPartnerSelected && !integration.selectedSystemType) {
- return {
- title: this.Labels.SYSTEM_TYPE_REQUIRED_TITLE,
- message: this.Labels.SYSTEM_TYPE_REQUIRED_MESSAGE
- };
- }
-
- // General integration incomplete message
- return {
- title: this.Labels.PARTNER_INTEGRATION_INCOMPLETE_TITLE,
- message: this.Labels.PARTNER_INTEGRATION_INCOMPLETE_MESSAGE
- };
- }
- }
-
- return null;
- }
-
- /**
- * Check if account fields are incomplete (username/password missing)
- */
- isAccountIncomplete(): boolean {
- if (!this.accEditor) {
- // If no account editor, consider account incomplete
- return true;
- }
-
- const accountValue = this.accEditor.value;
- if (!accountValue) {
- return true;
- }
-
- const hasUsername = accountValue?.username && accountValue.username.trim() !== '';
- const hasPassword = accountValue?.password && accountValue.password.trim() !== '';
- const isActive = accountValue?.active === true;
-
- // Account is incomplete if username, password, or active status is missing
- return !hasUsername || !hasPassword || !isActive;
- }
-
- /**
- * Get appropriate severity for constraint message
- */
- getConstraintSeverity(constraintDetails: { title: string; message: string }): string {
- // Account incomplete is informational (allows saving)
- if (constraintDetails.title === this.Labels.ACCOUNT_INCOMPLETE_TITLE) {
- return 'info';
- }
- // All other constraints are warnings (block saving)
- return 'warning';
- }
-
- /**
- * Get appropriate icon for constraint message
- */
- getConstraintIcon(constraintDetails: { title: string; message: string }): string {
- // Account incomplete uses info icon
- if (constraintDetails.title === this.Labels.ACCOUNT_INCOMPLETE_TITLE) {
- return 'pi-info-circle';
- }
- // All other constraints use warning icon (using ui-icon-warning as pi-exclamation-triangle has rendering issues)
- return 'ui-icon-warning';
- }
-
- /**
- * Get current account editor data for form data preservation
- * This method is called by the vehicle-partner-integration component
- * when navigating to account creation
- */
- getAccountEditorData(): any {
- if (this.accEditor && this.accEditor.valid) {
- return this.accEditor.value;
- }
- return null;
- }
-
- // ============================================================================
- // VEHICLE SAVE OPERATIONS
- // ============================================================================
-
saveVehicle() {
if (this.accEditor) {
const acc = this.accEditor.value;
@@ -360,233 +112,21 @@ export class VehicleEditComponent extends BaseComp implements OnInit, AfterViewI
this.selectedItem.password = acc.password;
this.selectedItem.active = acc.active;
}
-
if (this.selectedItem?.tracking && !this.selectedItem?.unitId) {
this.selectedItem.tracking = false;
}
-
- this.preparePartnerDataForBackend();
- this.store.dispatch(this._isNew ?
- new vehicleActions.Create(this.selectedItem) :
- new vehicleActions.Update(this.selectedItem)
- );
+ this.store.dispatch(this._isNew ? new vehicleActions.Create(this.selectedItem) : new vehicleActions.Update(this.selectedItem));
}
- private preparePartnerDataForBackend(): void {
- if (this.partnerData && this.partnerData.selectedPartner && this.partnerData.selectedPartner !== SourceSystem.AGNAV && this.partnerData.selectedPartnerData) {
- this.selectedItem.partnerInfo = {
- partner: this.partnerData.selectedPartnerData._id!,
- partnerAircraftId: this.partnerData.selectedPartnerAircraft || null,
- systemType: this.partnerData.systemType || SystemTypes.PLATINUM, // Include system type from partner integration
- metadata: {
- partnerSystem: this.partnerData.selectedPartnerData.name,
- partnerCode: this.partnerData.selectedPartnerData.partnerCode,
- aircraftData: this.partnerData.selectedPartnerAircraftDetails,
- syncStatus: this.partnerData.selectedPartnerAircraftDetails ? OperationalStatus.PENDING : null,
- lastSync: null,
- connectionStatus: OperationalStatus.CONNECTED
- }
- };
-
- if (this.partnerData.selectedPartnerAircraftDetails?.tailNumber) {
- this.selectedItem.tailNumber = this.partnerData.selectedPartnerAircraftDetails.tailNumber;
- }
-
- // Clean up legacy properties
- delete this.selectedItem.partnerSystem;
- delete this.selectedItem.partnerAircraftId;
- delete this.selectedItem.partnerAircraftData;
- } else {
- this.selectedItem.partnerInfo = {
- partner: null,
- partnerAircraftId: null,
- systemType: this.partnerData?.systemType || SystemTypes.PLATINUM, // Preserve system type even for AgNav
- metadata: null
- };
-
- // Clean up legacy properties
- delete this.selectedItem.partnerSystem;
- delete this.selectedItem.partnerAircraftId;
- delete this.selectedItem.partnerAircraftData;
- }
+ goBack() {
+ this.router.navigate(['/entities/aircraft/', { id: this.vehicle._id }]);
}
- goBack(savedVehicle?: any) {
- // Use savedVehicle if provided (from CREATE_SUCCESS), otherwise use this.vehicle
- const vehicleToCheck = savedVehicle || this.vehicle;
-
- // If this was a newly created vehicle that was successfully saved, add query param to show tooltip
- const vehicleSuccessfullySaved = vehicleToCheck?._id && vehicleToCheck._id !== '0';
-
- if (this.isNew && vehicleSuccessfullySaved) {
- this.router.navigate(['/entities/aircraft'], {
- queryParams: { newVehicleCreated: vehicleToCheck._id }
- });
- } else {
- this.router.navigate(['/entities/aircraft']);
- }
- }
-
- // ============================================================================
- // FORM DATA RESTORATION HELPERS
- // ============================================================================
-
- /**
- * Restore vehicle form data after Account Does Not Exist flow
- * @param formData The stored form data to restore
- */
- private restoreFormData(formData: any): void {
- if (!formData || !this.selectedItem) {
- return;
- }
-
- try {
- // Restore basic vehicle properties
- if (formData.name) this.selectedItem.name = formData.name;
- if (formData.vehicleType !== undefined) this.selectedItem.vehicleType = formData.vehicleType;
- if (formData.model) this.selectedItem.model = formData.model;
- if (formData.tailNumber) this.selectedItem.tailNumber = formData.tailNumber;
- if (formData.unitId) this.selectedItem.unitId = formData.unitId;
- if (formData.desc) this.selectedItem.desc = formData.desc;
- if (formData.color) this.selectedItem.color = formData.color;
-
- // Restore account credentials to vehicle object (for backward compatibility)
- if (formData.username) this.selectedItem.username = formData.username;
- if (formData.password) this.selectedItem.password = formData.password;
- if (formData.active !== undefined) this.selectedItem.active = formData.active;
-
- // Restore account editor form data if available and component is ready
- if (formData.accountEditor && this.accEditor) {
- // Use a timeout to ensure the account editor is fully initialized
- setTimeout(() => {
- if (this.accEditor) {
- this.accEditor.writeValue(formData.accountEditor);
- console.log('Restored account editor data:', formData.accountEditor);
- }
- }, 100);
- }
-
- // Trigger change detection to update the UI
- this.cdr.detectChanges();
-
- console.log('Successfully restored vehicle form data');
- } catch (error) {
- console.error('Error restoring form data:', error);
- }
- }
-
- // ============================================================================
- // COMPUTED PROPERTIES
- // ============================================================================
-
get canActivateVehicle() {
return this.authSvc.canActivateVehicle;
}
- get isPartnerSystemSelected(): boolean {
- return this.partnerData?.selectedPartner !== null && this.partnerData?.selectedPartner !== SourceSystem.AGNAV;
+ ngOnDestroy() {
+ super.ngOnDestroy();
}
-
- get canEditPartnerFields(): boolean {
- return this.partnerData?.partnerValidation?.accountExists &&
- this.partnerData?.partnerValidation?.authenticationValid &&
- !this.partnerData?.partnerValidation?.isValidating;
- }
-
- get canEditBasicFields(): boolean {
- return !this.partnerData?.partnerValidation?.isValidating;
- }
-
- get canSaveVehicle(): boolean {
- if (!this.selectedItem?.name || this.selectedItem.name.trim() === '') {
- return false;
- }
-
- const basicFieldsValid = this.selectedItem?.name?.trim() &&
- this.selectedItem?.model &&
- this.selectedItem?.vehicleType !== undefined;
-
- // Check account editor validity
- const accountValid = !this.accEditor || this.accEditor.form?.valid;
-
- if (!this.isPartnerSystemSelected) {
- return basicFieldsValid && accountValid;
- }
-
- if (!this.partnerData?.partnerValidation?.accountExists || !this.partnerData?.partnerValidation?.authenticationValid) {
- return basicFieldsValid && accountValid;
- }
-
- return basicFieldsValid && accountValid && !!this.partnerData?.selectedPartnerAircraft;
- }
-
- get saveButtonTooltip(): string {
- if (this.isPartnerSystemSelected) {
- if (!this.partnerData?.partnerValidation?.accountExists) {
- return Labels.SAVE_TOOLTIP_NO_ACCOUNT;
- }
- if (!this.partnerData?.partnerValidation?.authenticationValid) {
- return Labels.SAVE_TOOLTIP_AUTH_FAILED;
- }
- const partnerName = this.partnerData?.selectedPartnerData?.name || Labels.GENERIC_PARTNER;
- return `${Labels.SAVE_TOOLTIP_BASE_MESSAGE} ${partnerName} ${Labels.SAVE_TOOLTIP_INTEGRATION_SUFFIX}`;
- }
- return Labels.SAVE_TOOLTIP_NATIVE;
- }
-
- get partnerSystemName(): string {
- return this.partnerData?.selectedPartnerData?.name || 'partner system';
- }
-
- /**
- * Check if account editor is valid
- * For partner systems, account editor is hidden, so validation is skipped
- */
- get isAccountValid(): boolean {
- if (this.isPartnerSystemSelected) {
- return true; // No account editor for partner systems
- }
- return !this.accEditor || this.accEditor.form?.valid;
- }
-
- // ============================================================================
- // PARTNER VEHICLE ACTIVATION METHODS
- // ============================================================================
-
- /**
- * Determines if a partner system vehicle can be activated
- * Requires: partner account exists, authentication valid, and aircraft selected
- */
- canActivatePartnerVehicle(): boolean {
- if (!this.isPartnerSystemSelected) {
- return false;
- }
-
- return this.partnerData?.partnerValidation?.accountExists === true &&
- this.partnerData?.partnerValidation?.authenticationValid === true &&
- !!this.partnerData?.selectedPartnerAircraft;
- }
-
- /**
- * Returns constraint message explaining why partner vehicle cannot be activated
- */
- getPartnerActivationConstraintMessage(): string {
- if (!this.isPartnerSystemSelected) {
- return '';
- }
-
- if (!this.partnerData?.partnerValidation?.accountExists) {
- return Labels.PARTNER_ACCOUNT_REQUIRED_FOR_ACTIVATION;
- }
-
- if (!this.partnerData?.partnerValidation?.authenticationValid) {
- return Labels.PARTNER_AUTH_REQUIRED_FOR_ACTIVATION;
- }
-
- if (!this.partnerData?.selectedPartnerAircraft) {
- return Labels.PARTNER_AIRCRAFT_REQUIRED_FOR_ACTIVATION;
- }
-
- return '';
- }
-}
\ No newline at end of file
+}
diff --git a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.css b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.css
index b824541..acf2f40 100644
--- a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.css
+++ b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.css
@@ -1,292 +1,3 @@
-.highlight-btn {
- background-color: #4CAF50;
- /* $primaryColor */
+.highlight-btn{
+ background-color: green;
}
-
-/* Custom Aircraft Review Message - Enhanced UX Layout */
-.aircraft-review-container {
- background-color: #FFF8E1;
- /* Warning background - AgMission amber light */
- border: 1px solid #FFC107;
- /* $amber */
- border-radius: 6px;
- margin: 0.75rem 0;
- padding: 1rem 1.25rem;
- display: flex;
- align-items: flex-start;
- /* Top-align for better text flow */
- gap: 0.875rem;
- /* Optimized spacing for visual hierarchy */
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
- /* Subtle depth */
-}
-
-.aircraft-review-container .pi-info-circle {
- color: #FF8F00 !important;
- /* $amberHover - dark warning color for good contrast */
- font-size: 1.375em !important;
- flex-shrink: 0;
- margin-top: 0.125rem;
- /* Optical alignment with text baseline */
- line-height: 1;
-}
-
-.aircraft-review-message {
- color: #212121 !important;
- /* $textColor - high contrast text */
- font-weight: 500;
- font-size: 1rem;
- line-height: 1.4;
- /* Optimized line height for readability */
- flex: 1;
- margin: 0;
- /* Remove default margins for precise control */
-}
-
-/* Custom Generic Error Message - Enhanced UX Layout */
-.generic-error-container {
- background-color: #FFEBEE;
- /* Error background - AgMission red light */
- border: 1px solid #F44336;
- /* $red */
- border-radius: 6px;
- margin: 0.75rem 0;
- padding: 1rem 1.25rem;
- display: flex;
- align-items: flex-start;
- /* Top-align for better text flow */
- gap: 0.875rem;
- /* Consistent spacing with warning */
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
- /* Subtle depth */
-}
-
-.generic-error-container .pi-exclamation-triangle {
- color: #C62828 !important;
- /* $redHover - dark error red for good contrast */
- font-size: 1.375em !important;
- flex-shrink: 0;
- margin-top: 0.125rem;
- /* Optical alignment with text baseline */
- line-height: 1;
-}
-
-.generic-error-message {
- color: #C62828 !important;
- /* $redHover - dark error text for contrast */
- font-weight: 500;
- font-size: 1rem;
- line-height: 1.4;
- /* Consistent line height */
- flex: 1;
- margin: 0;
- /* Remove default margins for precise control */
-}
-
-/* Generic Message Enhanced Styling */
-:host ::ng-deep generic-message {
- display: block;
- margin: 0.5rem 0;
-}
-
-:host ::ng-deep generic-message .icon-message {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 0.75rem;
- padding: 0.75rem;
-}
-
-/* Button Styling for Aircraft Review */
-:host ::ng-deep generic-message .amber-btn {
- background-color: #FFC107;
- /* $amber */
- border-color: #FF8F00;
- /* $amberHover */
- color: #212121;
- /* $textColor for good contrast on yellow */
- font-weight: 600;
- padding: 0.75rem 1.5rem;
- min-height: 44px;
- /* Accessibility: Touch target size */
- border-radius: 4px;
- transition: all 0.2s ease;
-}
-
-:host ::ng-deep generic-message .amber-btn:hover {
- background-color: #FF8F00;
- /* $amberHover */
- border-color: #f9a825;
- /* $accentLightColor */
- transform: translateY(-1px);
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-/* System Type Display Styles */
-.system-type-display {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
- min-height: 44px;
- /* Accessibility: Larger touch targets */
-}
-
-/* Responsive Design - Improved Accessibility */
-@media (max-width: 768px) {
- .system-type-display {
- display: inline-flex;
- /* Keep inline for proper alignment with ui-column-title */
- gap: 6px;
- align-items: flex-start;
- vertical-align: top;
- }
-
- .partner-code-badge {
- font-size: 0.8rem;
- /* Maintain readability on mobile */
- padding: 3px 6px;
- min-width: 60px;
- }
-
- .auth-status-indicator {
- font-size: 0.8rem;
- margin-left: 4px;
- padding: 3px 6px;
- min-width: 28px;
- min-height: 32px;
- /* Maintain touch target on mobile */
- }
-
- .auth-status-indicator i {
- font-size: 0.75rem;
- }
-}
-
-@media (max-width: 480px) {
- .system-type-display {
- gap: 4px;
- }
-
- .partner-badge {
- font-size: 0.75rem;
- padding: 2px 6px;
- letter-spacing: 0.3px;
- }
-
- .partner-code-badge {
- font-size: 0.75rem;
- padding: 2px 5px;
- min-width: 50px;
- }
-
- .auth-status-indicator {
- font-size: 0.75rem;
- min-width: 24px;
- }
-}
-
-/* Table Column Specific Styles - Enhanced for Accessibility */
-.ui-table .ui-table-tbody>tr>td .system-type-display {
- min-width: 140px;
- /* Increased for better layout */
- padding: 4px 0;
-}
-
-/* Improved Focus Management for Table Cells */
-:host ::ng-deep .ui-table .ui-table-tbody>tr>td:focus-within {
- outline: 2px solid #4CAF50;
- /* $primaryColor for focus */
- outline-offset: 1px;
-}
-
-/* Dark Theme Support - Enhanced Contrast */
-@media (prefers-color-scheme: dark) {
- .partner-code-badge {
- background-color: #757575;
- /* $grayBgColor */
- color: #ffffff;
- /* $primaryTextColor */
- border-color: #bdbdbd;
- /* $dividerColor */
- }
-
- .partner-code-badge:hover,
- .partner-code-badge:focus {
- background-color: #616161;
- /* Darker gray */
- border-color: #e8e8e8;
- /* $hoverBgColor */
- }
-
- .auth-status-indicator.auth-valid {
- color: #A5D6A7;
- /* $primaryLightColor */
- background-color: rgba(165, 214, 167, 0.2);
- border-color: rgba(165, 214, 167, 0.4);
- }
-
- .auth-status-indicator.auth-invalid {
- color: #EF5350;
- /* Lighter red for dark theme */
- background-color: rgba(239, 83, 80, 0.2);
- border-color: rgba(239, 83, 80, 0.4);
- }
-
- .auth-status-indicator.auth-validating {
- color: #f9a825;
- /* $accentLightColor */
- background-color: rgba(249, 168, 37, 0.2);
- border-color: rgba(249, 168, 37, 0.4);
- }
-}
-
-/* ============================================================================
-PACKAGE ACTIVATION TOOLTIP STYLES
-============================================================================ */
-
-/* Highlight effect for package checkbox when tooltip is shown */
-.package-activation-highlight .p-checkbox-box {
- border: 2px solid #FFC107 !important;
- /* $amber */
- box-shadow: 0 0 8px rgba(255, 193, 7, 0.4) !important;
- /* $amber with transparency */
- animation: pulseGlow 2s ease-in-out infinite;
-}
-
-@keyframes pulseGlow {
- 0% {
- box-shadow: 0 0 8px rgba(255, 193, 7, 0.4);
- /* $amber */
- }
-
- 50% {
- box-shadow: 0 0 16px rgba(255, 193, 7, 0.6);
- /* $amber */
- }
-
- 100% {
- box-shadow: 0 0 8px rgba(255, 193, 7, 0.4);
- /* $amber */
- }
-}
-
-/* ============================================================================
-AIRCRAFT REVIEW BANNER – NO-CHANGES CONFIRM BUTTON
-============================================================================ */
-
-/* Flex column body: stacks message text + button vertically inside the flex row.
- Takes the flex:1 growth previously on .aircraft-review-message. */
-.aircraft-review-body {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- /* Center message text and button horizontally */
- text-align: center;
- gap: 10px;
-}
-
-/* No custom CSS needed for the confirm button - uses .highlight-btn
- (already defined at top of this file) with PrimeNG default button layout.
- This matches the existing toolbar button pattern (ui-icon-* + pButton). */
\ No newline at end of file
diff --git a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.html b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.html
index 2f167cd..aa8c965 100644
--- a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.html
+++ b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.html
@@ -4,8 +4,7 @@
-
+
@@ -13,53 +12,30 @@
-
-
-
-
-
-
- {{ status?.message }}
-
-
-
-
-
+
-
+ Aircraft List
-
{{ col.header }}
+
{{ col.header }}
-
+
-
-
-
+
+
+
@@ -71,11 +47,6 @@
{{ rowData[col.field] | vehicleType }}
-
-
-
-
-
@@ -84,8 +55,7 @@
-
+
@@ -94,10 +64,7 @@
-
-
+
@@ -115,8 +82,7 @@
- Unit ID is missing. Please enter a Unit ID to enable the
- tracking feature.
+ Unit ID is missing. Please enter a Unit ID to enable the tracking feature.{{ resolveFieldData(rowData, col.field) }}
@@ -138,16 +104,12 @@
-
-
+
+
-
+
-
+
@@ -155,30 +117,12 @@
Max vehicles: {{numOfVehicle}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.ts b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.ts
index 8106fe7..fe8cdc1 100644
--- a/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.ts
+++ b/Development/client/src/app/entities/vehicle/vehicle-list/vehicle-list.component.ts
@@ -5,23 +5,18 @@ import { ConfirmationService, SelectItem } from 'primeng/api';
import { Vehicle } from '../../models/vehicle.model';
import * as vehicleActions from '../../actions/vehicle.actions';
import * as fromEntity from '../../reducers';
-import { RoleIds, globals, vehTypes, VehType, SourceSystem, Labels } from '@app/shared/global';
+import { RoleIds, globals, vehTypes, VehType } from '@app/shared/global';
import { DateUtils, Utils } from '@app/shared/utils';
import { BaseComp } from '@app/shared/base/base.component';
-import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
-import { BadgeFactoryService } from '@app/shared/services/badge-factory.service';
-import { BadgeConfig } from '@app/shared/badge/badge-config.model';
import { getSubIntentState, getSubscriptionStatus, selectLimit } from '@app/reducers';
import { SUB, SubAppErr, SubTexts, SubType, createSubStatus, SubKeys, ACTIVE, TRACKING, hasVendorErr } from '@app/profile/common';
import { Limit, Status } from '@app/domain/models/subscription.model';
-import { map, switchMap, take } from 'rxjs/operators';
+import { map, switchMap, tap } from 'rxjs/operators';
import { ClearSubscriptionStatus, Compound, GotoMyServices } from '@app/actions/subscription.actions';
import { SubscriptionService } from '@app/domain/services/subscription.service';
import { FetchSubPlans } from '@app/actions/sub-plans.actions';
import { User } from '@app/accounts/models/user.model';
import { UserService } from '@app/domain/services/user.service';
-import { PartnerService } from '@app/partners/services/partner.service';
-import { PopupTooltipService } from '@app/shared/popup-tooltip/popup-tooltip.service';
const HIGHLIGHT = 'highlight-btn';
@@ -43,7 +38,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
readonly COLOR = 'color';
readonly MODEL = 'model';
readonly UNIT_ID = 'unitId';
- readonly SOURCE_SYSTEM = 'sourceSystem';
vehicles: Vehicle[] = [];
vehiclesChanged = false;
@@ -70,39 +64,11 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
vendorErr: boolean;
user: User;
- // Track if we're in review aircraft flow (from checkout/manage-subscription)
- private isReviewAircraftFlow = false;
-
- // ============================================================================
- // NEW VEHICLE TOOLTIP STATE
- // ============================================================================
-
- // Track newly created vehicle to show package activation tooltip
- newVehicleId: string | null = null;
- packageTooltipShown: boolean = false;
-
- // ============================================================================
- // PARTNER AUTHENTICATION STATUS CACHING
- // ============================================================================
-
- // Cache authentication status per partner to avoid repeated API calls
- private partnerAuthCache = new Map();
-
- // Per-partner debounce timers for authentication checks
- private authCheckTimers = new Map();
-
BASE_FIELDS = [
{ field: 'name', header: globals.name, filtered: true },
- { field: 'tailNumber', header: $localize`:@@tailNumber:Tail Number`, filtered: true },
{ field: this.VEHICLE_TYPE, header: $localize`:@@type:Type` },
{ field: this.MODEL, header: $localize`:@@model:Model` },
{ field: this.ACTIVE, header: globals.active, width: '5%' },
- { field: this.SOURCE_SYSTEM, header: $localize`:@@systemType:System Type` }, // NEW COLUMN
];
PACKAGE_ACTIVE_FIELDS = [
@@ -130,10 +96,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
private readonly confirmService: ConfirmationService,
private readonly subSvc: SubscriptionService,
private readonly userSvc: UserService,
- private readonly partnerService: PartnerService,
- private readonly partnerUtils: PartnerUtilsService,
- private readonly badgeFactory: BadgeFactoryService,
- private readonly popupTooltipService: PopupTooltipService
) {
super();
this.acTypes = [
@@ -146,15 +108,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
ngOnInit() {
this.user = this.route.snapshot.data['user'];
this.clearNeedReview();
-
- // Check for newly created vehicle query parameter
- this.checkForNewVehicleTooltip();
-
- // Track if we're in review aircraft flow via query param
- this.route.queryParams.pipe(take(1)).subscribe((params) => {
- this.isReviewAircraftFlow = params['reviewFlow'] === 'true';
- });
-
this.initVehList();
this.initStatus();
this.store.dispatch(new Compound([
@@ -168,363 +121,23 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
this.user.needReview = false;
this.userSvc.saveUser(this.user).subscribe({
error: (err) => {
+ console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
});
}
}
- // ============================================================================
- // NEW VEHICLE TOOLTIP METHODS
- // ============================================================================
-
- /**
- * Check for newly created vehicle and prepare to show package activation tooltip
- */
- checkForNewVehicleTooltip() {
- this.sub$.add(
- this.route.queryParams.subscribe(params => {
- if (params.newVehicleCreated && !this.packageTooltipShown) {
- this.newVehicleId = params.newVehicleCreated;
- // Remove the query parameter to clean up the URL
- this.router.navigate([], {
- relativeTo: this.route,
- queryParams: {},
- replaceUrl: true
- });
- }
- })
- );
- }
-
- /**
- * Show package activation tooltip for newly created vehicle
- * Only shown if vehicle meets all eligibility requirements
- */
- showPackageActivationTooltip(vehicleId: string) {
- if (!this.newVehicleId || this.newVehicleId !== vehicleId || this.packageTooltipShown) {
- return;
- }
-
- // Check if vehicle is eligible for tooltip
- if (!this.shouldShowAircraftReadyTooltip(vehicleId)) {
- // EDGE CASE: Check if partner auth is still validating
- const vehicle = this.vehicles.find(v => v._id === vehicleId);
- if (vehicle?.partnerInfo?.partner && !this.partnerUtils.isNativeSystem(vehicle.partnerInfo.partner)) {
- const authStatus = this.getPartnerAuthStatus(vehicle.partnerInfo.partner);
-
- if (authStatus.isValidating) {
- // Wait for auth validation to complete
- let retryAttempts = 0;
- const maxRetries = 10; // Max 5 seconds (10 * 500ms)
- const checkInterval = setInterval(() => {
- retryAttempts++;
- const updatedStatus = this.getPartnerAuthStatus(vehicle.partnerInfo.partner);
-
- if (!updatedStatus.isValidating || retryAttempts >= maxRetries) {
- clearInterval(checkInterval);
-
- if (retryAttempts >= maxRetries) {
- return;
- }
-
- // Re-check eligibility after auth completes
- if (this.shouldShowAircraftReadyTooltip(vehicleId)) {
- this.showPackageActivationTooltip(vehicleId);
- }
- }
- }, 500); // Check every 500ms
-
- return; // Don't show tooltip yet
- }
- }
-
- return; // Don't show tooltip if requirements not met
- }
-
- // Set flag immediately to prevent duplicate calls during setTimeout delay
- this.packageTooltipShown = true;
-
- // Wait for DOM updates and table rendering
- setTimeout(() => {
- // Find the package checkbox using the ID we added to the template
- const checkboxContainer = document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement;
-
- if (checkboxContainer) {
- // Get the actual checkbox element for precise positioning
- const checkboxBox = checkboxContainer.querySelector('.p-checkbox-box') as HTMLElement ||
- checkboxContainer.querySelector('.ui-chkbox-box') as HTMLElement ||
- checkboxContainer.querySelector('.p-checkbox') as HTMLElement;
-
- // Use the visual checkbox box if found, otherwise the container
- const targetElement = checkboxBox || checkboxContainer;
-
- if (targetElement) {
- // Add highlight class for visual emphasis
- checkboxContainer.classList.add('package-activation-highlight');
-
- // Position tooltip on the left side of the package active column
- const isMobile = window.innerWidth <= 768;
- const preferredPosition = isMobile ? 'bottom' : 'left'; // Changed from 'right' to 'left'
-
- // Check if package can be activated and customize tooltip accordingly
- const canActivate = this.canActivatePackageForVehicle(vehicleId);
- const tooltipConfig = this.getPackageActivationTooltipConfig(vehicleId, canActivate);
-
- const tooltipRef = this.popupTooltipService.showActionReminder(
- tooltipConfig.message,
- tooltipConfig.actionText,
- targetElement,
- {
- position: preferredPosition,
- autoHide: false, // Keep visible until user takes action
- title: tooltipConfig.title,
- severity: tooltipConfig.severity
- }
- );
-
- // Subscribe to action button click
- if (tooltipRef && tooltipRef.instance) {
- tooltipRef.instance.actionClicked.subscribe(() => {
- this.handlePackageActivationAction(vehicleId, canActivate);
- });
- }
- }
- }
- }, 500); // Allow time for DOM rendering
- }
-
- /**
- * Check if package can be activated for the given vehicle
- */
- canActivatePackageForVehicle(vehicleId: string): boolean {
- if (!this.pkgLimit) {
- return true; // No limits, can activate
- }
-
- const activePackageVehicles = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles);
- const maxAllowed = this.pkgLimit?.airCraft?.numOfVehicle || 0;
- return activePackageVehicles.length < maxAllowed;
- }
-
- /**
- * Check if vehicle has proper account credentials
- * Required for showing "Aircraft Ready" tooltip
- *
- * Note: Passwords are not returned from backend for security.
- * For newly created vehicles, we assume password was set because
- * backend validation would fail without it.
- *
- * @param vehicleId Vehicle ID to check
- * @returns true if vehicle has username and active=true
- */
- hasProperAccountCredentials(vehicleId: string): boolean {
- const vehicle = this.vehicles.find(v => v._id === vehicleId);
-
- if (!vehicle) {
- return false;
- }
-
- // Partner aircraft: Only require active status (no username/password needed)
- if (vehicle.partnerInfo?.partner && !this.partnerUtils.isNativeSystem(vehicle.partnerInfo.partner)) {
- return vehicle.active === true;
- }
-
- // Native AgMission aircraft: Require username and active status
- const hasUsername = vehicle.username && vehicle.username.trim() !== '';
- const isActive = vehicle.active === true;
-
- // Note: Password field is not included in backend response for security
- // For newly created vehicles (via newVehicleCreated query param),
- // we assume password was provided since backend validates this
-
- // Both username and active must be present for native aircraft
- return hasUsername && isActive;
- }
-
- /**
- * Check if vehicle has valid partner authentication (if applicable)
- * For AgNav native systems, returns true immediately
- * For partner systems, checks cached authentication status
- *
- * @param vehicleId Vehicle ID to check
- * @returns true if partner auth is valid or not required
- */
- hasValidPartnerAuthForVehicle(vehicleId: string): boolean {
- const vehicle = this.vehicles.find(v => v._id === vehicleId);
-
- if (!vehicle) {
- return false;
- }
-
- // Use existing partner authentication validation
- return this.hasValidPartnerAuth(vehicle);
- }
-
- /**
- * Check if vehicle is eligible to show "Aircraft Ready" tooltip
- *
- * Requirements:
- * 1. Has proper account credentials (username + active=true)
- * 2. Package limit has not been reached
- * 3. Partner authentication is valid (if applicable)
- *
- * @param vehicleId Vehicle ID to check
- * @returns true if all conditions are met
- */
- shouldShowAircraftReadyTooltip(vehicleId: string): boolean {
- // Requirement 1: Check account credentials
- const hasCredentials = this.hasProperAccountCredentials(vehicleId);
- if (!hasCredentials) {
- return false;
- }
-
- // Requirement 2: Check package limit
- const canActivate = this.canActivatePackageForVehicle(vehicleId);
- if (!canActivate) {
- return false;
- }
-
- // Requirement 3: Check partner authentication (if applicable)
- const hasValidAuth = this.hasValidPartnerAuthForVehicle(vehicleId);
- if (!hasValidAuth) {
- return false;
- }
-
- // All checks passed
- return true;
- }
-
- /**
- * Get tooltip configuration based on whether package can be activated
- */
- getPackageActivationTooltipConfig(vehicleId: string, canActivate: boolean) {
- if (canActivate) {
- return {
- title: Labels.AIRCRAFT_READY_TITLE,
- message: Labels.PACKAGE_ACTIVATION_REMINDER,
- actionText: Labels.ACTIVATE_PACKAGE_ACTION,
- severity: 'warning' as const
- };
- } else {
- const activeCount = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles).length;
- const maxAllowed = this.pkgLimit?.airCraft?.numOfVehicle || 0;
-
- return {
- title: Labels.PACKAGE_LIMIT_REACHED_TITLE,
- message: `${Labels.PACKAGE_LIMIT_REACHED_MESSAGE} (${activeCount}/${maxAllowed})`,
- actionText: Labels.MANAGE_PACKAGE_LIMIT_ACTION,
- severity: 'error' as const
- };
- }
- }
-
- /**
- * Handle the action button click in the tooltip
- */
- handlePackageActivationAction(vehicleId: string, canActivate: boolean) {
- if (canActivate) {
- // Activate package for this vehicle
- this.activatePackageForVehicle(vehicleId);
- } else {
- // Show options for managing package limits
- this.showPackageLimitOptions(vehicleId); // Pass vehicleId for consistent positioning
- }
- }
-
- /**
- * Activate package for the specified vehicle
- */
- activatePackageForVehicle(vehicleId: string) {
- const vehicle = this.vehicles.find(v => v._id === vehicleId);
- if (vehicle && !vehicle.pkgActive) {
- // Simulate checkbox change
- vehicle.pkgActive = true;
- this.vehSelChange(vehicle, this.PACKAGE_ACTIVE);
-
- // Trigger backend update directly without confirmation dialog
- this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles }));
-
- // Hide tooltip and show success feedback
- this.popupTooltipService.hideAll();
-
- // Show success message after a brief delay to allow for update processing
- setTimeout(() => {
- // Use consistent positioning with package activation tooltip
- const isMobile = window.innerWidth <= 768;
- const successPosition = isMobile ? 'bottom' : 'left';
-
- this.popupTooltipService.showSuccess(
- Labels.PACKAGE_ACTIVATED_SUCCESS,
- document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement,
- {
- autoHide: true,
- autoHideDelay: 3000,
- title: Labels.SUCCESS_TITLE,
- position: successPosition // Use consistent positioning
- }
- );
- }, 300);
- }
- }
-
- /**
- * Show options for managing package limits
- */
- showPackageLimitOptions(vehicleId: string) {
- // Hide current tooltip
- this.popupTooltipService.hideAll();
-
- // Use consistent positioning with package activation tooltip
- const isMobile = window.innerWidth <= 768;
- const warningPosition = isMobile ? 'bottom' : 'left';
-
- // Find the same target element (checkbox) for consistent positioning
- const targetElement = document.querySelector(`#package-checkbox-${vehicleId}`) as HTMLElement;
-
- // For now, show constraint message component or navigate to upgrade
- // This could be enhanced to show a modal with specific options
- this.popupTooltipService.showWarning(
- Labels.PACKAGE_LIMIT_UPGRADE_MESSAGE,
- targetElement, // Use the same target as other tooltips
- {
- autoHide: true,
- autoHideDelay: 5000,
- title: Labels.UPGRADE_REQUIRED_TITLE,
- position: warningPosition // Use consistent positioning
- }
- );
- }
-
initVehList() {
this.sub$ = this.store.select(fromEntity.getAllVehicles).pipe(
map((vehicles) => {
this.vehicles = vehicles;
this.vehSelLastUpdated = this.createVehSelections(vehicles);
this.vehiclesChanged = this.isVehSelChanged();
-
- // Show package activation tooltip after vehicles are loaded and view is initialized
- if (this.newVehicleId && vehicles.length > 0) {
- setTimeout(() => {
- this.showPackageActivationTooltip(this.newVehicleId);
- }, 500);
- }
-
- // Call resolveVehicleList when vehicles are loaded to ensure aircraft limits are enforced
- if (vehicles && vehicles.length > 0) {
- this.resolveVehicleList();
-
- // Show package activation tooltip for newly created vehicle after table renders
- if (this.newVehicleId && !this.packageTooltipShown) {
- // Use setTimeout to ensure the table is fully rendered
- setTimeout(() => {
- this.showPackageActivationTooltip(this.newVehicleId!);
- }, 500);
- }
- }
})
).subscribe({
error: (err) => {
+ console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
});
@@ -568,6 +181,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
}))
).subscribe({
error: (err) => {
+ console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
}));
@@ -591,21 +205,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
rowData[this.PACKAGE_ACTIVE_DATE] = currentUTCDate;
}
- // Hide package activation tooltip when user activates package for newly created vehicle
- if (type === this.PACKAGE_ACTIVE && rowData[this.PACKAGE_ACTIVE] &&
- this.newVehicleId === rowData._id && this.packageTooltipShown) {
- this.popupTooltipService.hideAll();
-
- // Remove highlight class
- const checkboxContainer = document.querySelector(`#package-checkbox-${rowData._id}`) as HTMLElement;
- if (checkboxContainer) {
- checkboxContainer.classList.remove('package-activation-highlight');
- }
-
- this.newVehicleId = null; // Reset to prevent showing again
- this.packageTooltipShown = false;
- }
-
if (!this.vehSelCurrent) this.vehSelCurrent = this.createVehSelections(this.vehicles);
this.vehSelCurrent[rowData[this.ID]][type] = rowData[type];
this.vehiclesChanged = this.isVehSelChanged();
@@ -624,20 +223,11 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
}
private resolveVehicleList() {
- // Ensure we have both vehicles data and limits before proceeding
- if (!this.vehicles || this.vehicles.length === 0) {
- return;
- }
-
- if (!this.pkgLimit && !this.trkLimit) {
- return;
- }
-
const trkVehicles = this.getVehicles(this.TRACKING, this.vehicles);
const pkgActiveVehs = this.getVehicles(this.PACKAGE_ACTIVE, this.vehicles);
- const isTrkVehicleAboveLimit = this.trkLimit && trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
- const isPkgActiveVehicleAboveLimit = this.pkgLimit && pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
+ const isTrkVehicleAboveLimit = trkVehicles.length > this.trkLimit?.airCraft?.numOfVehicle;
+ const isPkgActiveVehicleAboveLimit = pkgActiveVehs.length > this.pkgLimit?.airCraft?.numOfVehicle;
if (isTrkVehicleAboveLimit || isPkgActiveVehicleAboveLimit) {
this.vehicles = this.vehicles.map((veh) => ({
@@ -645,7 +235,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
tracking: isTrkVehicleAboveLimit ? trkVehicles.slice(0, this.trkLimit?.airCraft?.numOfVehicle).some((trkVeh) => trkVeh._id === veh._id) : veh.tracking,
pkgActive: isPkgActiveVehicleAboveLimit ? pkgActiveVehs.slice(0, this.pkgLimit?.airCraft?.numOfVehicle).some((pkgActiveVeh) => pkgActiveVeh._id === veh._id) : veh.pkgActive
}));
-
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles, type: SUB.AC_REVIEW }));
}
}
@@ -670,6 +259,7 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
})
).subscribe({
error: (err) => {
+ console.log(err);
this.status = createSubStatus(SubAppErr.AC_LIST_ERR);
}
})
@@ -716,284 +306,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
this.store.dispatch(new vehicleActions.Select(this.currVehicle));
}
- /**
- * Get partner display name for badge
- */
- getPartnerDisplayName(vehicle: Vehicle): string {
- // Check if vehicle has partner integration data
- if (vehicle.partnerInfo?.metadata?.partnerSystem) {
- return vehicle.partnerInfo.metadata.partnerSystem;
- }
-
- // Default to AgNav brand name (non-translatable) if no partner info exists
- return Labels.AGNAV_BRAND_NAME;
- }
-
- /**
- * Get source system for vehicle (used for SOURCE_SYSTEM column)
- */
- getSourceSystem(vehicle: Vehicle): string {
- return vehicle.partnerInfo?.metadata?.partnerSystem || SourceSystem.AGNAV;
- }
-
- /**
- * Badge Configuration Methods (using BadgeFactoryService)
- * Uses configuration-driven badge component for consistent styling
- */
-
- /**
- * Get badge configuration for partner name (system type badge)
- */
- getPartnerNameBadge(vehicle: Vehicle): BadgeConfig {
- const sourceSystem = this.getSourceSystem(vehicle);
- const partnerName = this.getPartnerDisplayName(vehicle);
- const badge = this.badgeFactory.createSystemBadge(sourceSystem, partnerName);
-
- // Override tooltip with component-specific tooltip logic
- return {
- ...badge,
- tooltip: this.getPartnerTooltip(vehicle)
- };
- }
-
- /**
- * Get badge configuration for authentication status (icon only)
- */
- getAuthStatusBadge(vehicle: Vehicle): BadgeConfig {
- const partnerId = vehicle.partnerInfo?.partner;
- const authStatus = partnerId ? this.getPartnerAuthStatus(partnerId) : null;
-
- const badge = this.badgeFactory.createAuthStatusBadge(
- authStatus?.isAuthenticated || false,
- authStatus?.isValidating || false,
- partnerId || null
- );
-
- // Override tooltip with component-specific tooltip logic
- return {
- ...badge,
- tooltip: this.getPartnerAuthTooltip(vehicle)
- };
- }
-
- /**
- * Get badge configuration for partner code (tail number badge)
- */
- getPartnerCodeBadge(vehicle: Vehicle): BadgeConfig {
- const badge = this.badgeFactory.createPartnerCodeBadge(vehicle.tailNumber || '');
-
- // Override tooltip with component-specific tooltip logic
- return {
- ...badge,
- tooltip: this.getPartnerCodeTooltip(vehicle)
- };
- }
-
- /**
- * Get tooltip text for partner information
- */
- getPartnerTooltip(vehicle: Vehicle): string {
- if (!vehicle.partnerInfo?.metadata?.partnerSystem) {
- return Labels.AGMISSION_NATIVE_SYSTEM;
- }
-
- const partnerName = vehicle.partnerInfo.metadata.partnerSystem;
- const lastSync = vehicle.partnerInfo.metadata.lastSync
- ? this.formatDate(new Date(vehicle.partnerInfo.metadata.lastSync))
- : Labels.NEVER;
-
- return `${partnerName} - ${Labels.LAST_SYNC_PREFIX} ${lastSync}`;
- }
-
- /**
- * Get tooltip text for partner code (tail number)
- */
- getPartnerCodeTooltip(vehicle: Vehicle): string {
- return `${Labels.TAIL_NUMBER_PREFIX} ${vehicle.tailNumber}`;
- }
-
- // ============================================================================
- // PARTNER AUTHENTICATION STATUS METHODS
- // ============================================================================
-
- /**
- * Get authentication status for a partner system (cached)
- */
- getPartnerAuthStatus(partnerId: string): { isAuthenticated: boolean; isValidating: boolean; error?: string } {
- const cached = this.partnerAuthCache.get(partnerId);
-
- if (!cached) {
- // Start validation for this partner if not in cache
- this.schedulePartnerAuthCheck(partnerId);
- return { isAuthenticated: false, isValidating: true };
- }
-
- // Return cached status
- return {
- isAuthenticated: cached.isAuthenticated,
- isValidating: cached.isValidating,
- error: cached.error
- };
- }
-
- /**
- * Schedule authentication check for a partner (debounced per partner)
- */
- private schedulePartnerAuthCheck(partnerId: string): void {
- // Mark as validating
- this.partnerAuthCache.set(partnerId, {
- isAuthenticated: false,
- isValidating: true,
- lastChecked: new Date()
- });
-
- // Clear existing timer for this specific partner
- const existingTimer = this.authCheckTimers.get(partnerId);
- if (existingTimer) {
- clearTimeout(existingTimer);
- }
-
- // Schedule check for this specific partner after short delay
- const timer = setTimeout(() => {
- this.performPartnerAuthCheck(partnerId);
- }, 100);
-
- // Store the timer for this partner
- this.authCheckTimers.set(partnerId, timer);
- }
-
- /**
- * Perform authentication check for a specific partner using centralized service method
- */
- private async performPartnerAuthCheck(partnerId: string): Promise {
- try {
- const currentCustomerId = this.authSvc.byPUserId;
- if (!currentCustomerId) {
- console.warn('No current customer ID available for partner auth check');
- return;
- }
-
- // Use centralized validation method
- const result = await this.partnerService.validatePartnerAuthentication(
- currentCustomerId,
- partnerId
- );
-
- // Cache the result with appropriate error mapping
- this.partnerAuthCache.set(partnerId, {
- isAuthenticated: result.isValid,
- isValidating: false,
- lastChecked: new Date(),
- error: result.isValid ? undefined : this.mapAuthErrorMessage(result.errorMessage)
- });
-
- } catch (error) {
- console.error(`Error checking partner ${partnerId} authentication:`, error);
-
- // Cache the error
- this.partnerAuthCache.set(partnerId, {
- isAuthenticated: false,
- isValidating: false,
- lastChecked: new Date(),
- error: error.message || Labels.UNKNOWN_ERROR
- });
- } finally {
- // Clean up the timer for this partner
- this.authCheckTimers.delete(partnerId);
- }
- }
-
- /**
- * Map authentication error messages from centralized service to user-friendly labels
- */
- private mapAuthErrorMessage(errorMessage?: string): string {
- if (!errorMessage) {
- return Labels.AUTHENTICATION_FAILED_SHORT;
- }
-
- if (errorMessage.includes('No system users found')) {
- return Labels.NO_SYSTEM_ACCOUNT_FOUND;
- }
-
- if (errorMessage.includes('credentials are missing')) {
- return Labels.MISSING_CREDENTIALS;
- }
-
- if (errorMessage.includes('Authentication test failed')) {
- return Labels.AUTHENTICATION_FAILED_SHORT;
- }
-
- // Default to authentication failed for any other error
- return Labels.AUTHENTICATION_FAILED_SHORT;
- }
-
- /**
- * Check if a partner system has valid authentication
- */
- hasValidPartnerAuth(vehicle: Vehicle): boolean {
- const partnerId = vehicle.partnerInfo?.partner;
- if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
- return true; // AgNav doesn't need external auth
- }
-
- const authStatus = this.getPartnerAuthStatus(partnerId);
- return authStatus.isAuthenticated;
- }
-
- /**
- * Check if a partner system is currently validating authentication
- */
- isPartnerAuthValidating(vehicle: Vehicle): boolean {
- const partnerId = vehicle.partnerInfo?.partner;
- if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
- return false; // AgNav doesn't need validation
- }
-
- const authStatus = this.getPartnerAuthStatus(partnerId);
- return authStatus.isValidating;
- }
-
- /**
- * Get authentication status icon class for partner
- */
- getPartnerAuthIconClass(vehicle: Vehicle): string {
- const partnerId = vehicle.partnerInfo?.partner;
- if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
- return 'ui-icon-check'; // AgNav is always valid
- }
-
- const authStatus = this.getPartnerAuthStatus(partnerId);
-
- if (authStatus.isValidating) {
- return 'pi pi-spin pi-spinner';
- } else if (authStatus.isAuthenticated) {
- return 'ui-icon-vpn-key';
- } else {
- return 'ui-icon-warning';
- }
- }
-
- /**
- * Get authentication status tooltip for partner
- */
- getPartnerAuthTooltip(vehicle: Vehicle): string {
- const partnerId = vehicle.partnerInfo?.partner;
- if (!partnerId || this.partnerUtils.isNativeSystem(partnerId)) {
- return Labels.AGMISSION_NATIVE_SYSTEM;
- }
-
- const authStatus = this.getPartnerAuthStatus(partnerId);
- const partnerName = vehicle.partnerInfo?.metadata?.partnerSystem || Labels.PARTNER_SYSTEM_DEFAULT;
-
- if (authStatus.isValidating) {
- return `${partnerName}: ${Labels.VALIDATING_AUTHENTICATION}`;
- } else if (authStatus.isAuthenticated) {
- return `${partnerName}: ${Labels.AUTHENTICATION_VALID}`;
- } else {
- return `${partnerName}: ${Labels.AUTHENTICATION_FAILED_WITH_ERROR} ${authStatus.error || Labels.UNKNOWN_ERROR}`;
- }
- }
-
newVehicle() {
this.router.navigate(['.', '0'], { relativeTo: this.route });
}
@@ -1019,11 +331,6 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
accept: () => {
this.store.dispatch(new vehicleActions.UpdateVehicles({ vehicles: this.vehicles }));
this.updateBtn?.nativeElement.classList.remove(HIGHLIGHT);
-
- // Navigate to service overview if in review aircraft flow
- if (this.isReviewAircraftFlow) {
- this.router.navigate(['/profile/myservices']);
- }
}
});
}
@@ -1043,34 +350,15 @@ export class VehicleListComponent extends BaseComp implements OnInit, AfterViewI
&& !this.vendorErr;
}
- isAircraftReviewStatus(): boolean {
- return this.subSvc.isStatusMatchingCode(this.status, SUB.AC_REVIEW);
- }
-
gotoMySubs() {
this.store.dispatch(new GotoMyServices());
}
- /**
- * Called when user confirms no aircraft changes are needed during review flow.
- * Navigates directly via Router (no reload) — unlike GotoMyServices which
- * always calls window.location.reload() after navigation.
- */
- noChangesToReview(): void {
- this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
- }
-
get canActivateVehicle() {
return this.authSvc.canActivateVehicle;
}
ngOnDestroy() {
- // Clear all partner-specific authentication check timers
- this.authCheckTimers.forEach((timer) => {
- clearTimeout(timer);
- });
- this.authCheckTimers.clear();
-
this.store.dispatch(new ClearSubscriptionStatus());
super.ngOnDestroy();
}
diff --git a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.css b/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.css
deleted file mode 100644
index 2ddf5eb..0000000
--- a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.css
+++ /dev/null
@@ -1,468 +0,0 @@
-/* Partner Integration Component Styles - AgMission Theme Compliance */
-
-/* Host element typography foundation - AgMission standards */
-:host {
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- /* $fontFamily - AgMission standard */
- line-height: 1.5;
- /* $lineHeight - AgMission standard */
- letter-spacing: 0.25px;
- /* $letterSpacing - AgMission standard */
-}
-
-.partner-validation-section {
- margin-top: 12px;
-}
-
-/* ============================================================================
-INTEGRATION STEPS INDICATOR - AgMission Project Color Compliance
-============================================================================ */
-
-.integration-steps {
- display: flex;
- align-items: center;
- margin: 16px 0 24px 0;
- padding: 12px;
- background: #ffffff;
- /* contentBgColor - AgMission content background */
- border-radius: 3px;
- /* AgMission standard border radius */
- border: 1px solid #bdbdbd;
- /* dividerColor - AgMission borders */
-}
-
-.step {
- display: flex;
- flex-direction: column;
- align-items: center;
- flex: 1;
- text-align: center;
- min-width: 120px;
-}
-
-.step-indicator {
- width: 32px;
- height: 32px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 8px;
- font-weight: bold;
- font-size: 14px;
- transition: all 0.3s ease;
- border: 2px solid #bdbdbd;
- /* dividerColor - AgMission neutral border */
- background: #ffffff;
- /* contentBgColor - AgMission white background */
- color: #757575;
- /* textSecondaryColor - AgMission secondary text */
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- /* $fontFamily - AgMission standard */
- line-height: 1.5;
- /* $lineHeight - AgMission standard */
- letter-spacing: 0.25px;
- /* $letterSpacing - AgMission standard */
-}
-
-.step.active .step-indicator {
- border-color: #03A9F4;
- /* blue - AgMission info color */
- background: #03A9F4;
- /* blue - AgMission info color */
- color: #ffffff;
- /* primaryTextColor - white text on colored backgrounds */
-}
-
-.step.completed .step-indicator {
- border-color: #4CAF50;
- /* primaryColor - AgMission main green */
- background: #4CAF50;
- /* primaryColor - AgMission main green */
- color: #ffffff;
- /* primaryTextColor - white text on colored backgrounds */
-}
-
-.step-label {
- font-size: 12px;
- font-weight: 500;
- color: #757575;
- /* textSecondaryColor - AgMission secondary text */
- line-height: 1.3;
- max-width: 100px;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- /* $fontFamily - AgMission standard */
- letter-spacing: 0.25px;
- /* $letterSpacing - AgMission standard */
-}
-
-.step.active .step-label {
- color: #03A9F4;
- /* blue - AgMission info color */
-}
-
-.step.completed .step-label {
- color: #4CAF50;
- /* primaryColor - AgMission main green */
-}
-
-.step-connector {
- flex: 0 0 auto;
- height: 2px;
- width: 40px;
- background: #bdbdbd;
- /* dividerColor - AgMission neutral */
- margin: 0 8px;
- border-radius: 1px;
-}
-
-.step.completed+.step-connector {
- background: #4CAF50;
- /* primaryColor - AgMission main green */
-}
-
-.step.active+.step-connector {
- background: linear-gradient(to right, #4CAF50 50%, #bdbdbd 50%);
- /* Green to neutral gradient */
-}
-
-/* ============================================================================
-PARTNER SYSTEM INDICATORS - AgMission Project Color Compliance
-============================================================================ */
-
-.partner-selection-with-indicator {
- display: flex;
- align-items: center;
- gap: 4px;
- width: fit-content;
-}
-
-.partner-selection-with-indicator p-dropdown {
- flex-shrink: 0;
-}
-
-.partner-selection-with-indicator p-dropdown .ui-dropdown {
- margin-right: 0 !important;
-}
-
-.success-indicator {
- color: #4CAF50 !important;
- /* primaryColor - AgMission main green */
- font-size: 1.2rem;
- opacity: 1;
- animation: fadeInScale 0.3s ease-in;
- margin-left: 0 !important;
- margin-right: 0 !important;
-}
-
-.loading-indicator {
- color: #03A9F4;
- /* blue - AgMission info color */
- font-size: 0.95rem;
- display: flex;
- align-items: center;
- gap: 4px;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- /* $fontFamily - AgMission standard */
- letter-spacing: 0.25px;
- /* $letterSpacing - AgMission standard */
- margin-left: 0 !important;
- margin-right: 0 !important;
-}
-
-.loading-indicator i {
- font-size: 1.1rem;
-}
-
-/* ============================================================================
-VALIDATION LOADING INDICATOR - AgMission Project Color Compliance
-============================================================================ */
-
-.validation-loading-indicator {
- color: #03A9F4 !important;
- /* blue - AgMission info color */
- margin-left: 0 !important;
- margin-right: 0 !important;
- font-size: 1.1rem;
-}
-
-/* ============================================================================
-AIRCRAFT INFORMATION PANEL - AgMission Project Color Compliance
-============================================================================ */
-
-.enhanced-aircraft-info-panel {
- background: linear-gradient(135deg, #E8F5E8 0%, #ffffff 100%);
- /* Light green to white gradient matching AgMission success styling */
- border: 1px solid #4CAF50;
- /* primaryColor - AgMission main green */
- border-radius: 3px;
- /* AgMission standard border radius - matches constraint-message */
- padding: 12px 16px;
- /* Matches constraint-message content padding */
- margin: 12px 0;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- /* Matches constraint-message shadow */
- transition: all 0.3s ease-in-out;
- /* Matches constraint-message transition */
- position: relative;
- overflow: hidden;
-}
-
-.enhanced-aircraft-info-panel:hover {
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
- /* Matches constraint-message hover shadow */
- transform: translateY(-1px);
- /* Matches constraint-message hover effect */
-}
-
-.aircraft-info-content {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- /* Matches constraint-message content gap */
-}
-
-.aircraft-info-icon {
- font-size: 1.125rem;
- /* Matches constraint-message icon size: 18px */
- color: #4CAF50;
- /* primaryColor - AgMission main green */
- margin-top: 2px;
- /* Matches constraint-message icon alignment */
- flex-shrink: 0;
-}
-
-.aircraft-info-text {
- flex: 1;
- min-width: 0;
- /* Matches constraint-message text container */
-}
-
-.aircraft-info-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 8px;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.aircraft-info-title {
- font-size: 0.875rem;
- /* 14px - matches constraint-message title size */
- font-weight: 500;
- /* Matches constraint-message title weight */
- color: #2E7D32;
- /* primaryDarkColor - darker green for headers */
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- /* $fontFamily - AgMission standard */
- line-height: 1.5;
- /* Matches constraint-message line height */
- letter-spacing: 0.25px;
- /* $letterSpacing - AgMission standard */
- margin-bottom: 4px;
- /* Matches constraint-message title margin */
-}
-
-.aircraft-details {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.detail-row {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 0.8125rem;
- /* 13px - matches constraint-message description size */
- line-height: 1.5;
- /* Matches constraint-message line height */
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- /* $fontFamily - AgMission standard */
- letter-spacing: 0.25px;
- /* $letterSpacing - AgMission standard */
- color: #212121;
- /* textColor - matches constraint-message description */
- margin: 0;
- word-wrap: break-word;
- /* Matches constraint-message description */
-}
-
-.detail-row strong {
- color: #2E7D32;
- /* primaryDarkColor - darker green for labels */
- font-weight: 500;
- min-width: 80px;
-}
-
-.system-type-row {
- align-items: flex-start;
-}
-
-.system-type-value {
- color: #4CAF50;
- /* primaryColor - AgMission success color for selected system type */
- font-weight: 600;
- /* Bold text for emphasis */
-}
-
-.system-type-pending {
- color: #f39c12;
- /* Warning color */
- font-style: italic;
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-/* ============================================================================
-DISABLED PREVIEW STYLING
-============================================================================ */
-
-.preview-disabled {
- opacity: 0.6;
-}
-
-/* ============================================================================
-ANIMATIONS
-============================================================================ */
-
-@keyframes fadeInScale {
- 0% {
- opacity: 0;
- transform: scale(0.8);
- }
-
- 100% {
- opacity: 1;
- transform: scale(1);
- }
-}
-
-/* ============================================================================
-RESPONSIVE DESIGN - Mobile and Tablet
-============================================================================ */
-
-@media (max-width: 768px) {
- .integration-steps {
- flex-direction: column;
- gap: 16px;
- padding: 16px 12px;
- }
-
- .step {
- min-width: auto;
- flex-direction: row;
- align-items: center;
- gap: 12px;
- justify-content: flex-start;
- text-align: left;
- }
-
- .step-indicator {
- margin-bottom: 0;
- width: 28px;
- height: 28px;
- font-size: 12px;
- }
-
- .step-label {
- font-size: 14px;
- max-width: none;
- }
-
- .step-connector {
- display: none;
- }
-
- .enhanced-aircraft-info-panel {
- padding: 10px 12px;
- /* Matches constraint-message mobile padding */
- margin: 8px 0;
- /* Matches constraint-message mobile margin */
- max-width: 100%;
- /* Matches constraint-message mobile width */
- }
-
- .aircraft-info-content {
- gap: 10px;
- /* Matches constraint-message mobile gap */
- }
-
- .aircraft-info-icon {
- font-size: 1rem;
- /* 16px - matches constraint-message mobile icon size */
- }
-
- .aircraft-info-title {
- font-size: 0.8125rem;
- /* 13px - matches constraint-message mobile title size */
- }
-
- .detail-row {
- font-size: 0.75rem;
- /* 12px - matches constraint-message mobile description size */
- }
-}
-
-@media (max-width: 480px) {
- .integration-steps {
- padding: 12px 8px;
- }
-
- .step-indicator {
- width: 24px;
- height: 24px;
- font-size: 11px;
- }
-
- .step-label {
- font-size: 13px;
- }
-
- .partner-selection-with-indicator {
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
- }
-
- .enhanced-aircraft-info-panel {
- padding: 10px;
- margin: 8px 0;
- }
-
- .aircraft-info-icon {
- font-size: 1rem;
- /* Consistent with tablet size */
- }
-
- .aircraft-info-title {
- font-size: 0.75rem;
- /* Smaller for mobile screens */
- }
-
- .detail-row {
- font-size: 0.7rem;
- /* Smaller for mobile screens */
- }
-}
-
-/* ============================================================================
-INPUT FIELD WITH INLINE CONSTRAINT - Detached Mode Pattern
-============================================================================ */
-
-.input-with-inline-constraint {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.input-with-inline-constraint label {
- white-space: nowrap;
-}
-
-/* Position the constraint trigger button closer to dropdown */
-.input-with-inline-constraint ::ng-deep .agm-constraint-trigger {
- margin-right: 32px;
-}
\ No newline at end of file
diff --git a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.html b/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.html
deleted file mode 100644
index f6747bf..0000000
--- a/Development/client/src/app/entities/vehicle/vehicle-partner-integration/vehicle-partner-integration.component.html
+++ /dev/null
@@ -1,263 +0,0 @@
-
- Bill yearly (Next bill date: {{periodEnd |
- tsToDate: lang }})
+ Bill yearly (Next bill date: {{periodEnd | tsToDate: lang }})
- Bill monthly (Next bill date: {{periodEnd |
- tsToDate: lang }})
+ Bill monthly (Next bill date: {{periodEnd | tsToDate: lang }})
@@ -959,8 +334,7 @@
-
+
diff --git a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.spec.ts b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.spec.ts
new file mode 100644
index 0000000..5b29969
--- /dev/null
+++ b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.spec.ts
@@ -0,0 +1,413 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { DebugElement } from '@angular/core';
+import { UserModel } from '@app/auth/models/user.model';
+import { provideMockStore, MockStore } from '@ngrx/store/testing';
+import { ManageSubscriptionComponent } from './manage-subscription.component';
+import * as fromStore from '../../reducers/index';
+import { getSubscriptionStatus, getUnpaidInvoices } from '@app/reducers';
+import { AppSharedModule } from '@app/shared/app-shared.module';
+import { SubscriptionService } from '@app/domain/services/subscription.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { AGNavSubscriptionShort, Status } from '@app/domain/models/subscription.model';
+import { CheckUnpaidSubscription, EditPackage, ResolvePayment, ShowUnpaidSubscription } from '@app/actions/subscription.actions';
+
+describe('ManageSubscriptionComponent', () => {
+ let component: ManageSubscriptionComponent;
+ let fixture: ComponentFixture;
+ let debugElement: DebugElement;
+ let store: MockStore;
+ let dispatchSpy: jasmine.Spy;
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ AppSharedModule,
+ HttpClientTestingModule,
+ ],
+ declarations: [
+ ManageSubscriptionComponent ],
+ providers: [
+ provideMockStore({
+ selectors: [
+ {
+ selector: fromStore.selectAuthUser,
+ value: {}
+ },
+ {
+ selector: fromStore.selectSubPkgs,
+ value: {}
+ },
+ {
+ selector: fromStore.selectSubAddons,
+ value: {}
+ },
+ {
+ selector: getUnpaidInvoices,
+ value: []
+ },
+ {
+ selector: getSubscriptionStatus,
+ value: {}
+ }
+ ]
+ }),
+ SubscriptionService
+ ]
+ })
+ .compileComponents();
+ }));
+ beforeEach(() => {
+ store = TestBed.inject(MockStore);
+ dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
+ });
+
+ describe('Test active subscriptions', () => {
+ const user: UserModel = {
+ _id: '1234',
+ username: 'bill@customer1.com',
+ roles: ['1'],
+ parent: '',
+ lang: 'en',
+ pre: 0,
+ membership: {
+ custId: 'cust_1234',
+ endOfPeriod: 13323,
+ subscriptions: [{
+ id: '1234',
+ status: 'active',
+ periodEnd: 124,
+ periodStart: 1245,
+ items: [{
+ price: 'ess_1',
+ quantity: 1
+ }],
+ type: 'package'
+ },
+ {
+ id: '3456',
+ status: 'active',
+ periodEnd: 124,
+ periodStart: 1245,
+ items: [{
+ price: 'addon_1',
+ quantity: 1
+ }],
+ type: 'addon'
+ }]
+ },
+ name: 'bill'
+ };
+ const packages: AGNavSubscriptionShort[] = [
+ {
+ id: '1234',
+ lookupKey: 'ess_1',
+ status: 'active'
+ }
+ ];
+ const addons: AGNavSubscriptionShort[] = [
+ {
+ id: '3456',
+ lookupKey: 'addon_1',
+ status: 'active'
+ }
+ ];
+ beforeEach(() => {
+ store.overrideSelector(fromStore.selectSubPkgs, packages);
+ store.overrideSelector(fromStore.selectSubAddons, addons);
+ store.overrideSelector(fromStore.selectAuthUser,user);
+ fixture = TestBed.createComponent(ManageSubscriptionComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should correctly display package and addons', () => {
+ const headerElts: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('.feature-header');
+ console.log(headerElts[0].innerText)
+ expect(headerElts[0].innerText).toEqual('Ag-Mission Essentials 1');
+ expect(headerElts[1].innerText).toEqual('Aircraft Tracking (Per Aircraft)')
+ });
+
+ it('should correctly trigger change package', () => {
+ const changePkgBtn: HTMLButtonElement = debugElement.nativeElement
+ .querySelector('[label="Change Subscription"]');
+ changePkgBtn.click();
+ expect(dispatchSpy).toHaveBeenCalledWith(new EditPackage())
+ });
+ });
+
+ describe('Test incomplete subscriptions', () => {
+ const user: UserModel = {
+ _id: '1231',
+ username: 'bob@customer2.com',
+ roles: ['1'],
+ parent: '',
+ lang: 'en',
+ pre: 0,
+ membership: {
+ custId: 'cust_1231',
+ endOfPeriod: 13323,
+ subscriptions: [{
+ id: '1231',
+ status: 'incomplete',
+ periodEnd: 124,
+ periodStart: 1245,
+ items: [{
+ price: 'ess_1',
+ quantity: 1
+ }],
+ type: 'package'
+ },
+ {
+ id: '3457',
+ status: 'incomplete',
+ periodEnd: 124,
+ periodStart: 1245,
+ items: [{
+ price: 'addon_1',
+ quantity: 1
+ }],
+ type: 'addon'
+ }]
+ },
+ name: 'bob'
+ };
+ const packages: AGNavSubscriptionShort[] = [
+ {
+ id: '1234',
+ lookupKey: 'ess_1',
+ status: 'incomplete'
+ }
+ ];
+ const addons: AGNavSubscriptionShort[] = [
+ {
+ id: '3456',
+ lookupKey: 'addon_1',
+ status: 'incomplete'
+ }
+ ];
+ const status: Status = {
+ code: 'incomplete',
+ message: 'Please resolve incomplete subscription'
+ };
+ beforeEach(() => {
+ store.overrideSelector(fromStore.selectSubPkgs, packages);
+ store.overrideSelector(fromStore.selectSubAddons, addons);
+ store.overrideSelector(fromStore.selectAuthUser, user);
+ store.overrideSelector(getSubscriptionStatus, status);
+ fixture = TestBed.createComponent(ManageSubscriptionComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should correctly display incomplete package and addons', () => {
+ const resolvBtn: HTMLButtonElement = debugElement.nativeElement
+ .querySelector('[label="Resolve"]');
+ expect(resolvBtn).toBeTruthy();
+ });
+ it('should correctly trigger resolve payment', () => {
+ const resolveBtn: HTMLButtonElement = debugElement.nativeElement
+ .querySelector('[label="Resolve"]');
+ resolveBtn.click();
+ expect(dispatchSpy).toHaveBeenCalledWith(new ResolvePayment())
+ });
+ it('should correctly display user name', () => {
+ const header: HTMLElement = debugElement.nativeElement
+ .querySelector('h1');
+ expect(header.innerHTML).toEqual('Hello bob@customer2.com');
+ });
+ });
+
+ describe('Test past_due subscriptions', () => {
+ const user: UserModel = {
+ _id: '1231',
+ username: 'bob@customer2.com',
+ roles: ['1'],
+ parent: '',
+ lang: 'en',
+ pre: 0,
+ membership: {
+ custId: 'cust_1231',
+ endOfPeriod: 13323,
+ subscriptions: [{
+ id: '1231',
+ status: 'past_due',
+ periodEnd: 124,
+ periodStart: 1245,
+ items: [{
+ price: 'ess_1',
+ quantity: 1
+ }],
+ type: 'package'
+ },
+ {
+ id: '3457',
+ status: 'past_due',
+ periodEnd: 124,
+ periodStart: 1245,
+ items: [{
+ price: 'addon_1',
+ quantity: 1
+ }],
+ type: 'addon'
+ }]
+ },
+ name: 'bob'
+ };
+ const packages: AGNavSubscriptionShort[] = [
+ {
+ id: '1234',
+ lookupKey: 'ess_1',
+ status: 'past_due'
+ }
+ ];
+ const addons: AGNavSubscriptionShort[] = [
+ {
+ id: '3456',
+ lookupKey: 'addon_1',
+ status: 'past_due'
+ }
+ ];
+ const status: Status = {
+ code: 'past_due',
+ message: 'Please resolve past due subscription'
+ };
+ beforeEach(() => {
+ store.overrideSelector(fromStore.selectSubPkgs, packages);
+ store.overrideSelector(fromStore.selectSubAddons, addons);
+ store.overrideSelector(fromStore.selectAuthUser, user);
+ store.overrideSelector(getSubscriptionStatus, status);
+ fixture = TestBed.createComponent(ManageSubscriptionComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should correctly display incomplete button and message', () => {
+ const resolvBtn: HTMLButtonElement = debugElement.nativeElement
+ .querySelector('[label="Resolve"]');
+ expect(resolvBtn).toBeTruthy();
+ });
+ it('should correctly trigger resolve payment', () => {
+ const resolvBtn: HTMLButtonElement = debugElement.nativeElement
+ .querySelector('[label="Resolve"]');
+ resolvBtn.click();
+ expect(dispatchSpy).toHaveBeenCalledWith(new ResolvePayment())
+ });
+ it('should correctly display user name', () => {
+ const header: HTMLElement = debugElement.nativeElement
+ .querySelector('h1');
+ expect(header.innerHTML).toEqual('Hello bob@customer2.com');
+ });
+ });
+
+ describe('Test unpaid subscriptions', () => {
+ const user: UserModel = {
+ _id: '1234',
+ username: 'bill@customer1.com',
+ roles: ['1'],
+ parent: '',
+ lang: 'en',
+ pre: 0,
+ membership: {
+ custId: 'cust_1234',
+ endOfPeriod: 13323,
+ subscriptions: [{
+ id: '1231',
+ status: 'unpaid',
+ periodEnd: 124,
+ periodStart: 1245,
+ items: [{
+ price: 'ess_1',
+ quantity: 1
+ }],
+ type: 'package'
+ },
+ {
+ id: '3457',
+ status: 'unpaid',
+ periodEnd: 124,
+ periodStart: 1245,
+ items: [{
+ price: 'addon_1',
+ quantity: 1
+ }],
+ type: 'addon'
+ }]
+ },
+ name: 'bill'
+ };
+ const packages: AGNavSubscriptionShort[] = [
+ {
+ id: '1234',
+ lookupKey: 'ess_1',
+ status: 'unpaid'
+ }
+ ];
+ const addons: AGNavSubscriptionShort[] = [
+ {
+ id: '3456',
+ lookupKey: 'addon_1',
+ status: 'unpaid'
+ }
+ ];
+ const status: Status = {
+ code: 'unpaid',
+ message: 'Please resolve unpaid subscription'
+ };
+ beforeEach(() => {
+ store.overrideSelector(fromStore.selectSubPkgs, packages);
+ store.overrideSelector(fromStore.selectSubAddons, addons);
+ store.overrideSelector(fromStore.selectAuthUser, user);
+ store.overrideSelector(getSubscriptionStatus, status);
+ fixture = TestBed.createComponent(ManageSubscriptionComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should correctly display unpaid button and message', () => {
+ const resolvBtn: HTMLButtonElement = debugElement.nativeElement
+ .querySelector('[label="Resolve Unpaid"]');
+ const errElt: HTMLElement = debugElement.nativeElement
+ .querySelector('.ui-messages-error');
+ expect(resolvBtn).toBeTruthy();
+ expect(errElt.innerText).toEqual('Please resolve unpaid subscription');
+ });
+ it('should correctly trigger resolve unpaid susbscription', () => {
+ const resolvBtn: HTMLButtonElement = debugElement.nativeElement
+ .querySelector('[label="Resolve Unpaid"]');
+ resolvBtn.click();
+ expect(dispatchSpy).toHaveBeenCalledWith(new ShowUnpaidSubscription())
+ });
+
+ describe('Test unpaid subscriptions with fresh instance', () => {
+ beforeEach(() => {
+ store.overrideSelector(getSubscriptionStatus, null);
+ fixture = TestBed.createComponent(ManageSubscriptionComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should be able to poll for unpaid subscription', () => {
+ expect(dispatchSpy).toHaveBeenCalledWith(new CheckUnpaidSubscription({user , freshInstance: true}));
+ });
+ });
+
+ describe('Test unpaid subscriptions with polling', () => {
+ const status: Status = {
+ code: 'resolving-unpaid',
+ message: 'Attempting to resolve unpaid subscription, Please wait...'
+ }
+ beforeEach(() => {
+ store.overrideSelector(getSubscriptionStatus, status);
+ fixture = TestBed.createComponent(ManageSubscriptionComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should display resolving unpaid message', () => {
+ const errElt: HTMLElement = debugElement.nativeElement
+ .querySelector('.ui-messages-error');
+ expect(errElt.innerText).toEqual('Attempting to resolve unpaid subscription, Please wait...');
+ });
+ });
+ });
+});
diff --git a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.ts b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.ts
index 3855e79..75c8116 100644
--- a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.ts
+++ b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.ts
@@ -1,41 +1,20 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
-import { CancelPollSubscription, ClearSubscriptionStatus, GotoServices, PollUnpaidSubscription, ResetSubscriptionIntent, ResolvePayment, Compound, InitSubscription, FetchLatestSubscriptionSuccess, FETCH_LATEST_SUBSCRIPTION_SUCCESS, StartBillingInfo, FetchDefaultPm, FetchPaymentMethodList, SetMode } from '@app/actions/subscription.actions';
+import { CancelPollSubscription, ClearSubscriptionStatus, GotoServices, PollUnpaidSubscription, ResetSubscriptionIntent, ResolvePayment, Compound, InitSubscription, FetchLatestSubscriptionSuccess, StartBillingInfo, FetchDefaultPm, FetchPaymentMethodList, SetMode } from '@app/actions/subscription.actions';
import { FetchUsage, ResetUsage } from '../actions/usage.actions';
import { selectSubPkgs, selectSubAddons, getSubscriptionStatus, getUnpaid, getSubscriptions, getDefPM, getPaymentMethods } from '../../reducers';
-import { catchError, filter, map, switchMap, take } from 'rxjs/operators';
+import { catchError, map, switchMap } from 'rxjs/operators';
import { User } from '@app/accounts/models/user.model';
-import { AGNavSubscriptionShort, Addon, Discount, Package, PaymentMethod, PendingPromoDetails, Status, StripeSubscription, Unpaid, UsageDetail, Invoice, InvoicePackage } from '@app/domain/models/subscription.model';
+import { AGNavSubscriptionShort, Addon, Discount, Package, PaymentMethod, Status, StripeSubscription, Unpaid, UsageDetail } from '@app/domain/models/subscription.model';
import { getUsageState } from '../selectors/profile.selector';
import { SubscriptionService } from '@app/domain/services/subscription.service';
-import { ActivePromoService, ActivePromo } from '@app/domain/services/active-promo.service';
import { SubAppErr, SUB, SubTexts, createSubStatus, SubStripe, SubType, Mode, subPlans, hasVendorErr } from '../common';
import { BaseComp } from '@app/shared/base/base.component';
-import { GC, globals, Labels } from '@app/shared/global';
+import { GC, globals } from '@app/shared/global';
import { of } from 'rxjs';
import { FETCH_SUB_PLANS_SUCCESS, FetchSubPlans, RESET_SUB_PLANS } from '@app/actions/sub-plans.actions';
import { DateUtils } from '@app/shared/utils';
import { IMembership, UserModel } from '@app/auth/models/user.model';
-import { BadgeConfig, BadgeType, BadgeSize } from '@app/shared/badge/badge-config.model';
-
-/**
- * Promo details provided by backend in subscription response (r962)
- */
-interface PromoDetails {
- hasPromo: boolean;
- name: string | null;
- discountDisplay: string | null;
- 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;
-}
enum EditDiaContentType { AUTO_RENEW, CONTINUE_TRIAL };
@@ -47,7 +26,6 @@ enum EditDiaContentType { AUTO_RENEW, CONTINUE_TRIAL };
export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnDestroy {
readonly SubTexts = SubTexts;
readonly globals = globals;
- readonly Labels = Labels;
readonly SubStripe = SubStripe;
readonly SubType = SubType;
readonly EditDiaContentType = EditDiaContentType;
@@ -84,57 +62,10 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
membership: IMembership;
user: UserModel;
- /** Country code from the user's saved billing address (e.g. 'CA', 'US') */
- billingCountry: string | null = null;
-
autoRenewChkbox: { [i: string]: boolean };
autoRenewChkboxDef: { [i: string]: boolean };
contTrialChkbox: { [i: string]: boolean };
contTrialChkboxDef: { [i: string]: boolean };
- isLoadingSubscriptions: boolean = false;
-
- /** Map of lookup keys to next bill amounts for display */
- nextBillAmounts: { [lookupKey: string]: string } = {};
-
- // ============================================================================
- // INVOICE PREVIEW PROPERTIES (Dual-Period Support)
- // ============================================================================
-
- /**
- * Current period charge amount (immediate billing)
- * Used when backend returns multiple invoices for deferred promo scenarios
- */
- currentPeriodCharge: { [lookupKey: string]: number } = {};
-
- /**
- * Next period charge amount (future billing cycle)
- * Only populated when deferred promo applies (100% FREE promo on quantity change)
- */
- nextPeriodCharge: { [lookupKey: string]: number } = {};
-
- /**
- * Flag indicating if next period has active promo
- * True when invoice.has_promo === true for next period invoice
- */
- hasPromoNextPeriod: { [lookupKey: string]: boolean } = {};
-
- /**
- * Next billing date (when next period charge will be billed)
- * Extracted from next period invoice.period_start or subscription.current_period_end
- */
- nextBillingDate: { [lookupKey: string]: Date } = {};
-
- /** r975+: pendingPromoDetails from invoice or subscription — keyed by lookupKey.
- * Present when a deferred 100% FREE promo is scheduled for the next billing period. */
- pendingPromoDetails: { [lookupKey: string]: PendingPromoDetails } = {};
-
- /** Formatted savings amount for pending promo badge (e.g. "$99.90"). Populated from
- * invoice.total_discount_amounts after retrieveNextInvoices completes. Keyed by lookupKey. */
- pendingPromoSavings: { [lookupKey: string]: string } = {};
-
- // ============================================================================
- // PRORATION CREDIT STATE (Issue 4 - Proration Credit Display)
- // ============================================================================
get hasValidTrialOffer(): boolean {
return this.authSvc.validateTrial(this.membership?.trials);
@@ -144,20 +75,9 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
vendorErr: boolean;
lang;
- /** Map of lookup keys to active promos for display */
- activePromos: Map = new Map();
-
- /**
- * Check if user has multiple subscription packages
- */
- get hasMultiplePackages(): boolean {
- return this.packages?.length > 1;
- }
-
constructor(
private readonly route: ActivatedRoute,
- private readonly subSvc: SubscriptionService,
- public readonly activePromoSvc: ActivePromoService
+ private readonly subSvc: SubscriptionService
) {
super();
this.profileUser = this.route.snapshot.data['user'];
@@ -176,227 +96,19 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
]));
this.initSub$();
this.initSubInfo();
- this.loadActivePromos();
- this.sub$.add(
- this.subSvc.getBillingAddress(this.user?._id).subscribe({
- next: (addr) => this.billingCountry = addr?.country ?? null,
- error: () => this.billingCountry = null
- })
- );
- }
-
- /**
- * Load active promos from backend and build lookup map
- * Handles both exact-match promos (priceKey specified) and type-only promos (priceKey: null)
- * Type-only promos apply to ALL items of that type (e.g., all packages or all addons)
- */
- private loadActivePromos(): void {
- this.activePromoSvc.getActivePromos().subscribe(promos => {
- this.activePromos = new Map();
- promos.forEach(p => {
- if (p.priceKey) {
- // Exact match promo - keyed by priceKey
- this.activePromos.set(p.priceKey, p);
- } else if (p.type) {
- // Type-only promo (priceKey is null) - applies to ALL of that type
- // Store with special key convention: "package_all" or "addon_all"
- this.activePromos.set(`${p.type}_all`, p);
- } else {
- // Universal promo (no type, no priceKey) - applies to EVERYTHING
- this.activePromos.set('package_all', p);
- this.activePromos.set('addon_all', p);
- }
- });
- });
- }
-
- /**
- * Get promo for a given lookup key (used in template)
- * Checks for exact match first, then falls back to type-only promo
- *
- * CRITICAL: Trial subscriptions NEVER show promos because:
- * 1. Trial IS the promotion - no additional discount needed
- * 2. Prevents confusing UX where promotional pricing shows during trial period
- * 3. Consistent with manage-services component behavior
- *
- * @param lookupKey Package or addon lookup key (e.g., 'ess_1', 'addon_1')
- * @param type Subscription type ('package' or 'addon') for type-only promo fallback
- * @returns ActivePromo if exists and subscription is not a trial, null otherwise
- */
- getPromoForLookupKey(lookupKey: string, type: 'package' | 'addon' = 'package'): ActivePromo | null {
- // CRITICAL: Hide promos for trial subscriptions (status='trialing')
- // Trial IS the promotion - user doesn't need to see additional promo badges
- const subscription = this.subscriptions?.find(sub =>
- sub.items?.data?.some(item => item?.price?.lookup_key === lookupKey)
- );
-
- if (subscription?.status === SubStripe.TRIALING) {
- return null; // Hide ALL promos for trial subscriptions
- }
-
- // ✅ FIX (2026-01-26): Only show promo if subscription actually has one applied
- // Prevents global activePromos from showing for existing subscriptions without promos
- // See: docs/current_work/.../2026-01-26-16-45-promo-display-unexpected-behavior-investigation.md
-
- // This component is used in manage-subscription context (user viewing their existing subscription)
- // Therefore, we should ONLY show promo if the subscription itself has a promo applied
- // DO NOT show available global promos for existing subscriptions
-
- // Check if subscription has promo applied via promoDetails (r948+)
- if (subscription?.promoDetails?.hasPromo) {
- // ✅ FIX (2026-01-28): Use MongoDB promo validUntil instead of Stripe coupon duration
- // Stripe coupons with duration='forever' don't have discount.end, so backend returns isTimeLimited=false
- // BUT MongoDB promo may have validUntil date that should be honored for expiry calculations
-
- // Try to get MongoDB promo data from activePromos map
- const mongoPromo = this.activePromos.get(lookupKey) ||
- this.activePromos.get(`${type}_all`) ||
- this.activePromos.get('package_all') ||
- this.activePromos.get('addon_all');
-
- // If MongoDB promo has validUntil, use that instead of Stripe's promoDetails
- if (mongoPromo && mongoPromo.validUntil) {
- const expiryDate = new Date(mongoPromo.validUntil);
- const now = new Date();
- const daysRemaining = Math.max(0, Math.ceil((expiryDate.getTime() - now.getTime()) / 86400000));
-
- return {
- type: type,
- priceKey: lookupKey,
- validUntil: mongoPromo.validUntil,
- name: mongoPromo.name || subscription.promoDetails.name || 'Active Promo',
- discountType: mongoPromo.discountType,
- discountValue: mongoPromo.discountValue,
- isTimeLimited: true, // MongoDB promo with validUntil is time-limited
- daysRemaining: daysRemaining
- };
- }
-
- // Fallback to Stripe promoDetails (for time-limited Stripe discounts)
- return {
- type: type,
- priceKey: lookupKey,
- validUntil: subscription.promoDetails.expiresAt || '',
- name: subscription.promoDetails.name || 'Active Promo',
- discountType: subscription.promoDetails.discountDisplay?.includes('FREE') ? 'free' : 'percent',
- discountValue: subscription.promoDetails.discountDisplay?.includes('FREE') ? 100 : 50,
- isTimeLimited: subscription.promoDetails.isTimeLimited,
- daysRemaining: subscription.promoDetails.daysRemaining
- };
- }
-
- // ❌ REMOVED (r955): subscription.discount field no longer returned by backend
- // Backend now returns promoDetails only (handled above)
- // No fallback needed - if promoDetails doesn't exist, no promo is active
-
- // ✅ NEW (2026-01-30): Case 2B - Renewal Promo for Subscriptions WITHOUT Promo Applied (Re-acquisition)
- // Show available global promo for subscriptions with auto-renew DISABLED
- // Target: Legacy customers who subscribed without promo, now have auto-renew OFF
- // Business Goal: Incentivize renewal by offering new promo to non-auto-renewing customers (churn reduction)
- // ✅ UPDATED (2026-02-24): Removed promoExpiry > subscriptionEnd gate — only check is validUntil > now.
- // The old gate hid the banner when the promo expired before the billing cycle end, which was too strict.
- if (subscription?.cancel_at_period_end) {
- // Query activePromos map for global promos (lookup_key, type_all, package_all, addon_all)
- const mongoPromo = this.activePromos.get(lookupKey) ||
- this.activePromos.get(`${type}_all`) ||
- this.activePromos.get('package_all') ||
- this.activePromos.get('addon_all');
-
- // Case 2B Condition: Promo must not yet be expired (validUntil > now)
- if (mongoPromo && mongoPromo.validUntil) {
- const promoExpiry = new Date(mongoPromo.validUntil);
- const now = new Date();
-
- if (promoExpiry > now) {
- const daysRemaining = Math.max(0, Math.ceil((promoExpiry.getTime() - now.getTime()) / 86400000));
-
- return {
- type: type,
- priceKey: lookupKey,
- validUntil: mongoPromo.validUntil,
- name: mongoPromo.name || 'Renewal Promo',
- discountType: mongoPromo.discountType,
- discountValue: mongoPromo.discountValue,
- isTimeLimited: true,
- daysRemaining: daysRemaining,
- isRenewalPromo: true // ✅ Case 2B flag - Distinguish renewal offer from existing promo
- };
- }
- }
- }
-
- // ✅ For existing subscriptions without promos and NOT Case 2B, do NOT show global activePromos
- // This prevents "Active Promo" label from appearing for non-promo subscriptions (Issue 1 fix)
- return null;
- }
-
- /** Returns the duration key for a pending promo row.
- * Drives the template's ng-container switch structure.
- *
- * Edge case: Stripe's schema allows duration="repeating" with durationInMonths=null
- * (open-ended repeating coupon with no month count). In that case we fall back to
- * "once" so the template renders "next billing period" rather than "for 0 months". */
- getPendingPromoDuration(pending: PendingPromoDetails): 'once' | 'repeating' | 'forever' {
- const d = (pending?.duration as 'once' | 'repeating' | 'forever') || 'once';
- if (d === 'repeating' && !pending?.durationInMonths) { return 'once'; }
- return d;
- }
-
- /** Returns the durationInMonths value for a pending repeating promo. */
- getPendingPromoDurationMonths(pending: PendingPromoDetails): number {
- return pending?.durationInMonths || 0;
}
private initSubInfo() {
try {
if (this.membership) {
- this.isLoadingSubscriptions = true;
-
- // ✅ CRITICAL: Reset usage state FIRST to clear stale data from previous navigation
- // This prevents showing old "Unlimited" values while waiting for fresh data
- this.store.dispatch(new ResetUsage());
-
this.store.dispatch(new InitSubscription({ custId: this.membership.custId }));
-
- // ✅ FIX: Use take(1) but filter for the specific custId we just dispatched
- // Problem: take(1) without filtering can complete with wrong customer's data
- // Solution: Filter by custId to ensure we get the right action for THIS component instance
- this.sub$.add(
- this.appActions.ofTypes([FETCH_LATEST_SUBSCRIPTION_SUCCESS])
- .pipe(
- filter((action: any) => action.payload?.membership?.custId === this.membership.custId),
- take(1)
- )
- .subscribe((action: any) => {
- const updatedMembership = action.payload.membership;
-
- // Use centralized utility to get latest package subscription with fresh data
- const latestPkg = this.subSvc.getLatestSubscription(
- updatedMembership.subscriptions,
- SubType.PACKAGE
- );
-
- if (latestPkg) {
- // Get MongoDB-prioritized maxAcres from fresh membership data
- const effectiveMaxAcres = this.subSvc.getEffectiveAcresLimit(
- latestPkg,
- updatedMembership.customLimits
- );
-
- this.store.dispatch(new FetchUsage({
- custId: this.membership.custId,
- byPuid: this.user._id,
- lookupKey: latestPkg.items?.[0]?.price as string,
- effectiveMaxAcres
- }));
- } else {
- this.store.dispatch(new ResetUsage());
- }
- })
- );
+ const pkg = this.membership.subscriptions?.find((sub) => sub.type === SubType.PACKAGE);
+ pkg
+ ? this.store.dispatch(new FetchUsage({ custId: this.membership.custId, byPuid: this.user._id, lookupKey: pkg.items?.[0]?.price as string }))
+ : this.store.dispatch(new ResetUsage());
}
} catch (err) {
- console.error('Manage subscription error:', err);
+ console.log(err);
this.status = createSubStatus(SubAppErr.MGE_SUB_ERR);
}
}
@@ -416,7 +128,7 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
})
).subscribe({
error: (err) => {
- console.error('Subscription fetch error:', err);
+ console.log(err);
this.status = createSubStatus(SubAppErr.MGE_SUB_ERR);
}
}));
@@ -447,7 +159,7 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
})
).subscribe({
error: (err) => {
- console.error('Addons fetch error:', err);
+ console.log(err);
this.status = createSubStatus(SubAppErr.MGE_SUB_ERR);
}
}));
@@ -509,7 +221,6 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
this.existIncompleteSub = this.subSvc.checkSubStatus(subs, SubStripe.INCOMPLETE, '===');
this.existPastDueSub = this.subSvc.checkSubStatus(subs, SubStripe.PAST_DUE, '===') || this.subSvc.checkSubStatus(subs, SubStripe.OVERDUE, '===');
this.existUnpaidSub = this.subSvc.checkSubStatus(subs, SubStripe.UNPAID, '===');
-
subs?.forEach((sub) => {
this.autoRenewChkbox = { ...this.autoRenewChkbox, [sub.lookupKey]: !sub.cancelAtPeriodEnd };
this.autoRenewChkboxDef = { ...this.autoRenewChkboxDef, [sub.lookupKey]: !sub.cancelAtPeriodEnd };
@@ -523,7 +234,6 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
return this.store.select(getSubscriptions).pipe(
switchMap((subscriptions) => {
this.subscriptions = subscriptions;
- this.isLoadingSubscriptions = false;
return this.store.select(getPaymentMethods);
}),
switchMap((paymentMethodList) => {
@@ -535,9 +245,6 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
if (this.subscriptions?.length === (this.packages?.length + this.addons?.length)) {
this.packages?.forEach((pkg) => assignPaymentMethod(pkg, pkg.id));
this.addons?.forEach((addon) => assignPaymentMethod(addon, addon.id));
-
- // Load next bill amounts after packages and addons are fully loaded
- this.loadAllNextBillAmounts();
}
this.hasActiveTrial = this.authSvc.hasActiveTrial(this.membership?.trials);
if (!this.subSvc.hasInValTaxLoc(this.subscriptions)) initSubBalance(unpaid, this.subscriptions);
@@ -546,7 +253,7 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
}),
).subscribe({
error: (err) => {
- console.error('Packages fetch error:', err);
+ console.log(err);
this.status = createSubStatus(SubAppErr.MGE_SUB_ERR);
}
}));
@@ -563,7 +270,7 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
})
).subscribe({
error: (err) => {
- console.error('Usage fetch error:', err);
+ console.log(err);
this.status = createSubStatus(SubAppErr.MGE_SUB_ERR);
}
}));
@@ -576,391 +283,6 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
});
}
- /**
- * Load next bill amount for a subscription by calling retrieveUpcomingInvoices API
- * Fetches upcoming invoice data and stores formatted amount for display
- *
- * Special handling for trial subscriptions:
- * - Stripe API cannot generate upcoming invoices for active trials (returns invoice_upcoming_none error)
- * - For trials, calculate expected post-trial amount from subscription plan data
- *
- * @param lookupKey Package or addon lookup key (e.g., 'ess_1', 'addon_1')
- * @param subscriptionId Stripe subscription ID
- */
- private loadNextBillAmount(lookupKey: string, subscriptionId: string): void {
- if (!this.membership?.custId || !subscriptionId) {
- console.warn('Cannot load next bill amount: missing custId or subscriptionId');
- return;
- }
-
- // Find the subscription to get package details
- const subscription = this.subscriptions?.find(sub => sub.id === subscriptionId);
- if (!subscription) {
- console.warn(`Cannot load next bill amount: subscription ${subscriptionId} not found`);
- return;
- }
-
- // Handle trial subscriptions: Stripe cannot generate upcoming invoices for trials
- // Calculate expected amount from plan data instead
- if (subscription.status === SubStripe.TRIALING) {
- const expectedAmount = this.calculateTrialPostAmount(subscription, lookupKey);
- if (expectedAmount !== null) {
- this.nextBillAmounts[lookupKey] = expectedAmount;
- return;
- }
- // If calculation fails, fall through to API call (will likely fail but has error handling)
- }
-
- // Build invoice package request
- // Use current UTC time instead of future period end to avoid Stripe validation errors
- // Stripe requires proration_date within current period or phase
- // Date.now() returns milliseconds since Unix epoch (UTC), divide by 1000 for seconds
- // This is timezone-independent and matches Stripe's Unix timestamp format (always UTC)
- // For annual subscriptions, current_period_end can be 1 year in future (outside Stripe's valid window)
- const currentTimeSeconds = Math.floor(Date.now() / 1000);
-
- // Determine if this is an addon or package subscription.
- // CRITICAL: sending addons:[] (empty) to the backend is interpreted as "cancel all addons",
- // which generates phantom proration credits. Must pass current quantity to get a clean renewal preview.
- const addonInfo = this.addons?.find(a => String(a.lookupKey) === lookupKey);
- const isAddon = !!addonInfo;
-
- const invoicePkg: InvoicePackage = {
- custId: this.membership.custId,
- package: isAddon ? '' : lookupKey, // Only set package for package lookup keys
- addons: isAddon
- ? [{ price: lookupKey, quantity: addonInfo.quantity ?? 1 }] // Pass current quantity - no change
- : [], // Package: addons empty, backend resolves from subscription data
- prorateTS: currentTimeSeconds // Current UTC time always valid for Stripe API
- };
-
- // Call API to get upcoming invoices
- this.subSvc.retrieveUpcomingInvoices(invoicePkg).subscribe({
- next: (invoices: Invoice[]) => {
- if (!invoices || invoices.length === 0) {
- console.warn(`No upcoming invoices returned for subscription ${subscriptionId}`);
- this.nextBillAmounts[lookupKey] = 'N/A';
- return;
- }
-
- // Filter to invoices for THIS subscription before path selection.
- // The backend may return invoices for multiple subscriptions in one response
- // (e.g., a package proration credit alongside an addon renewal).
- // In the genuine dual-invoice deferred-promo case both invoices share the
- // same subscriptionId, so this filter is safe.
- const subscriptionInvoices = invoices.filter(
- inv => !inv.subscription || inv.subscription === subscriptionId
- );
- const invoicesToProcess = subscriptionInvoices.length > 0 ? subscriptionInvoices : invoices;
-
- // Handle dual-invoice scenario (deferred promo with quantity change)
- if (invoicesToProcess.length > 1) {
- // Find current and next period invoices
- const currentInvoice = this.findInvoiceByPeriodType(invoicesToProcess, 'current');
- const nextInvoice = this.findInvoiceByPeriodType(invoicesToProcess, 'next');
-
- // Store current period charge (immediate billing)
- if (currentInvoice) {
- const currentAmountCents = currentInvoice.total ?? currentInvoice.amount_due ?? 0;
-
- // Store current period charge
- this.currentPeriodCharge[lookupKey] = currentAmountCents;
-
- // Display net total
- const currentAmountDollars = currentAmountCents / 100;
- this.nextBillAmounts[lookupKey] = `$${currentAmountDollars.toFixed(2)} US`;
- }
-
- // Store next period charge (future billing cycle)
- if (nextInvoice) {
- const nextAmountCents = nextInvoice.total ?? nextInvoice.amount_due ?? 0;
- this.nextPeriodCharge[lookupKey] = nextAmountCents;
- this.hasPromoNextPeriod[lookupKey] = nextInvoice.has_promo === true;
-
- // r975: populate pendingPromoDetails from whichever invoice carries it
- if (currentInvoice?.pendingPromoDetails) {
- this.pendingPromoDetails[lookupKey] = currentInvoice.pendingPromoDetails;
- } else if (nextInvoice?.pendingPromoDetails) {
- this.pendingPromoDetails[lookupKey] = nextInvoice.pendingPromoDetails;
- } else {
- delete this.pendingPromoDetails[lookupKey];
- }
-
- // Populate pending promo savings from next period invoice discount amounts
- const nextSavings = this.getInvoiceSavings(nextInvoice);
- if (nextSavings) {
- this.pendingPromoSavings[lookupKey] = nextSavings;
- } else {
- delete this.pendingPromoSavings[lookupKey];
- }
-
- // Extract next billing date from r975 field (when the charge is collected)
- this.nextBillingDate[lookupKey] = currentInvoice?.next_billing_date
- ? new Date(currentInvoice.next_billing_date * 1000)
- : new Date(subscription.current_period_end * 1000);
- }
- } else {
- // Standard single-invoice scenario
- const invoice = invoicesToProcess.find(inv => inv.subscription === subscriptionId) || invoicesToProcess[0];
-
- if (invoice) {
- const amountInCents = invoice.total ?? invoice.amount_due ?? 0;
-
- // Store current period charge
- this.currentPeriodCharge[lookupKey] = amountInCents;
-
- // Clear next period data (no deferred promo)
- this.nextPeriodCharge[lookupKey] = undefined;
- this.hasPromoNextPeriod[lookupKey] = false;
- // r975: pendingPromoDetails may be injected on standard invoices too
- if (invoice?.pendingPromoDetails) {
- this.pendingPromoDetails[lookupKey] = invoice.pendingPromoDetails;
- } else {
- delete this.pendingPromoDetails[lookupKey];
- // Detect discount-covered invoice: total = $0 but subtotal > $0 (Stripe-level active coupon).
- // Backend only injects pendingPromoDetails for deferred metadata-based coupons (r975+).
- // For already-active Stripe discounts the coupon simply zeroes the invoice total.
- if (amountInCents === 0 && (invoice.subtotal_excluding_tax ?? 0) > 0) {
- // Cross-reference already-loaded subscription data so the synthetic pending promo
- // carries real coupon metadata (duration, durationInMonths, discountDisplay).
- // this.packages / this.addons are populated before loadAllNextBillAmounts() runs.
- const existingSub = [...(this.packages || []), ...(this.addons || [])]
- .find(s => s.lookupKey === lookupKey);
- const subPromo = existingSub && existingSub.promoDetails && existingSub.promoDetails.hasPromo
- ? existingSub.promoDetails : null;
-
- this.pendingPromoDetails[lookupKey] = {
- isPending: true as const,
- appliesToNextPeriod: true as const,
- name: subPromo && subPromo.name ? subPromo.name : Labels.DISCOUNT_APPLIED,
- discountDisplay: subPromo && subPromo.discountDisplay ? subPromo.discountDisplay : Labels.DISCOUNT_DISPLAY_FALLBACK,
- percentOff: subPromo ? subPromo.percentOff : null,
- amountOff: subPromo ? subPromo.amountOff : null,
- currency: subPromo ? subPromo.currency : null,
- duration: subPromo ? subPromo.duration : null,
- durationInMonths: subPromo ? subPromo.durationInMonths : null,
- expiresAt: null,
- discountEndsAt: null,
- daysRemaining: null,
- daysUntilDiscountEnds: null,
- isTimeLimited: false as const,
- };
- }
- }
- // Populate pending promo savings from invoice discount amounts
- const savingsAmount = this.getInvoiceSavings(invoice);
- if (savingsAmount) {
- this.pendingPromoSavings[lookupKey] = savingsAmount;
- } else {
- delete this.pendingPromoSavings[lookupKey];
- }
-
- // r975: next_billing_date present = next charge date; absent = no upcoming charge
- this.nextBillingDate[lookupKey] = invoice.next_billing_date
- ? new Date(invoice.next_billing_date * 1000)
- : undefined;
-
- // Display net total
- const amountInDollars = amountInCents / 100;
- this.nextBillAmounts[lookupKey] = `$${amountInDollars.toFixed(2)} US`;
- } else {
- console.warn(`No invoice found for subscription ${subscriptionId}`);
- this.nextBillAmounts[lookupKey] = 'N/A';
- this.resetInvoiceState(lookupKey);
- }
- }
- },
- error: (err) => {
- console.error(`Failed to load next bill amount for ${lookupKey}:`, err);
-
- // Reset state on error
- this.resetInvoiceState(lookupKey);
-
- // Handle specific Stripe error codes
- if (err?.raw?.code === 'invoice_upcoming_none') {
- // Trial subscription without upcoming invoice - should have been handled above
- // Fallback: try to calculate from subscription data
- const fallbackAmount = this.calculateTrialPostAmount(subscription, lookupKey);
- this.nextBillAmounts[lookupKey] = fallbackAmount ?? 'See trial details';
- } else {
- // Other errors - display fallback message
- this.nextBillAmounts[lookupKey] = 'N/A';
- }
- }
- });
- }
-
- /**
- * Reset invoice preview state for a lookup key
- * Called on error or when clearing data
- *
- * @param lookupKey Package or addon lookup key
- */
- private resetInvoiceState(lookupKey: string): void {
- this.currentPeriodCharge[lookupKey] = undefined;
- this.nextPeriodCharge[lookupKey] = undefined;
- this.hasPromoNextPeriod[lookupKey] = false;
- this.nextBillingDate[lookupKey] = undefined;
- delete this.pendingPromoDetails[lookupKey];
- delete this.pendingPromoSavings[lookupKey];
- }
-
- /**
- * Extract and format the total savings amount from invoice discount amounts.
- * Uses invoice.total_discount_amounts as source of truth (set by Stripe on promo invoices).
- * @returns Formatted dollar string (e.g. "$99.90") or null if no discount applied.
- */
- private getInvoiceSavings(invoice: Invoice): string | null {
- const savings = invoice?.total_discount_amounts
- ?.reduce((sum, d) => sum + d.amount, 0) ?? 0;
- return savings > 0 ? `$${(savings / 100).toFixed(2)}` : null;
- }
-
- // ============================================================================
- // DUAL-INVOICE HELPER METHODS
- // ============================================================================
-
- /**
- * Find invoice by period_type metadata field
- * Backend returns invoices with period_type = "current" | "next" for deferred promos
- *
- * @param invoices - Array of invoice objects from backend
- * @param periodType - "current" or "next"
- * @returns Invoice object matching period type, or undefined
- */
- private findInvoiceByPeriodType(invoices: Invoice[], periodType: 'current' | 'next'): Invoice | undefined {
- // First try: Check period_type field (v3.1 backend adds this for dual invoices)
- let invoice = invoices.find(inv => inv.period_type === periodType);
-
- // Fallback: If no period_type metadata, use array order heuristic
- // Backend pattern: First invoice without period_type = current, second with period_type = next
- if (!invoice && invoices.length > 1) {
- if (periodType === 'current') {
- // Current period invoice: either has no period_type or is first in array
- invoice = invoices.find(inv => !inv.period_type) || invoices[0];
- } else if (periodType === 'next') {
- // Next period invoice: second in array as fallback
- invoice = invoices[1];
- }
- }
-
- // Single invoice case: treat as current period
- if (!invoice && invoices.length === 1 && periodType === 'current') {
- invoice = invoices[0];
- }
-
- return invoice;
- }
-
- // ============================================================================
- // PRORATION CREDIT PARSING (Issue 4 - Proration Credit Display)
- // ============================================================================
-
- /**
- * Load next bill amounts for all active subscriptions with auto-renew enabled
- * Called after packages and addons are loaded
- */
- private loadAllNextBillAmounts(): void {
- // Load for packages
- this.packages?.forEach(pkg => {
- const lookupKey = String(pkg.lookupKey);
-
- // r975: seed pendingPromoDetails immediately from subscription data so the
- // FREE badge appears on page load — invoice fetch will overwrite if needed.
- if (pkg.pendingPromoDetails) {
- this.pendingPromoDetails[lookupKey] = pkg.pendingPromoDetails;
- }
-
- if (this.autoRenewChkbox[lookupKey] &&
- (pkg.status === SubStripe.ACTIVE || pkg.status === SubStripe.TRIALING)) {
- this.loadNextBillAmount(lookupKey, pkg.id);
- }
- });
-
- // Load for addons
- this.addons?.forEach(addon => {
- const lookupKey = String(addon.lookupKey);
-
- // r975: same seed for addon subscriptions
- if (addon.pendingPromoDetails) {
- this.pendingPromoDetails[lookupKey] = addon.pendingPromoDetails;
- }
-
- if (this.autoRenewChkbox[lookupKey] &&
- (addon.status === SubStripe.ACTIVE || addon.status === SubStripe.TRIALING)) {
- this.loadNextBillAmount(lookupKey, addon.id);
- }
- });
- }
-
- /**
- * Calculate expected post-trial bill amount for trial subscriptions
- * Stripe API cannot generate upcoming invoices for active trials (when there is not any valid payment method on file),
- * so to make it simple, calculate to core total bill value (before tax) from plan data
- *
- * @param subscription The subscription object from Stripe
- * @param lookupKey The package/addon lookup key
- * @returns Formatted amount string or null if calculation fails
- */
- private calculateTrialPostAmount(subscription: any, lookupKey: string): string | null {
- try {
- // Get base amount from subscription plan (in cents)
- const baseAmountCents = subscription.plan?.amount ?? subscription.items?.data?.[0]?.price?.unit_amount ?? 0;
-
- if (baseAmountCents === 0) {
- console.warn(`Cannot calculate trial post amount: no plan amount found for ${lookupKey}`);
- return null;
- }
-
- // Check if subscription has promo discount
- let discountedAmount = baseAmountCents;
- const promoDetails = subscription.promoDetails;
-
- if (promoDetails?.hasPromo) {
- // Check for one-time coupons that have already been applied
- // For trials continuing to paid, one-time discounts don't apply to next bill
- const isOneTimeApplied = promoDetails.duration === 'once' ||
- promoDetails.discountEndsAt === 'applied';
-
- if (isOneTimeApplied) {
- // One-time discount already used - next bill is full price
- // No discount calculation needed
- } else {
- // Apply discount based on promo type
- if (promoDetails.percentOff) {
- // Percentage discount
- const discountPercent = promoDetails.percentOff / 100;
- discountedAmount = baseAmountCents * (1 - discountPercent);
- } else if (promoDetails.amountOff) {
- // Fixed amount discount (already in cents)
- discountedAmount = baseAmountCents - promoDetails.amountOff;
- }
- }
-
- // Ensure amount is not negative
- discountedAmount = Math.max(0, discountedAmount);
- }
-
- // Convert to dollars
- const amountInDollars = discountedAmount / 100;
-
- // Note: This is estimated amount before tax
- // Actual invoice will include tax calculation
- return `$${amountInDollars.toFixed(2)} US`;
- } catch (err) {
- console.error(`Error calculating trial post amount for ${lookupKey}:`, err);
- return null;
- }
- }
-
- /**
- * Returns true when the customer's billing address country is Canada (CA).
- * Reads from the stored billing address — authoritative and always current.
- */
- isCustomerInCanada(): boolean {
- return this.billingCountry === 'CA';
- }
-
isCompLoaded() {
return this.status?.code !== SubAppErr.MGE_SUB_ERR
&& this.status?.code !== SubAppErr._500_ERR
@@ -975,19 +297,6 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
return this.autoRenewChkbox && Object.values(this.autoRenewChkbox)?.some((checked) => checked === true);
}
- /**
- * Handle auto-renew checkbox change event
- * NOTE: We don't fetch next bill amount here because subscription hasn't been updated on backend yet
- * The actual API call happens after save() completes successfully
- *
- * @param lookupKey Package or addon lookup key
- * @param isChecked New checkbox state (true = auto-renew enabled)
- */
- onAutoRenewChange(lookupKey: string, isChecked: boolean): void {
- // Just update the checkbox state - actual next bill amount will be fetched after save()
- // This prevents showing $0.00 when the backend still has cancel_at_period_end: true
- }
-
isResolvePM() {
return this.pmDefaultErr && this.hasAutoRenew();
}
@@ -1024,134 +333,16 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
save() {
const updateAutoRenew = () => {
const currSubs = this.packages?.concat(this.addons);
-
- // Detect trial subscriptions that changed from cancel → continue (need billing setup)
- const trialSubsNeedingBilling = currSubs?.filter((sub) => {
- const fullSub = this.subscriptions?.find((s) => s.items?.data?.some((item) => item?.price?.lookup_key === sub.lookupKey));
- const isTrialing = fullSub?.status === SubStripe.TRIALING;
- const wasCanceling = this.autoRenewChkboxDef[sub.lookupKey] === false; // Previously unchecked
- const nowContinuing = this.autoRenewChkbox[sub.lookupKey] === true; // Now checked
- return isTrialing && wasCanceling && nowContinuing;
- }) || [];
-
- if (trialSubsNeedingBilling.length > 0) {
- // User enabled trial subscriptions → Navigate to billing-address
- let selPkg: Package;
- let selAddons: Addon[];
- let subIds: string[] = [];
-
- trialSubsNeedingBilling.forEach((sub) => {
- const lookupKey = String(sub.lookupKey); // Convert PriceUsd to string
- const isPkg = this.packages?.some((pkg) => pkg.lookupKey === sub.lookupKey);
- const isAddon = this.addons?.some((addon) => addon.lookupKey === sub.lookupKey);
-
- if (isPkg) {
- selPkg = { lookupKey, desc: subPlans[lookupKey].desc, price: subPlans[lookupKey].price };
- subIds = [...subIds, ...this.getSubIds(lookupKey)];
- }
-
- if (isAddon) {
- const fullAddonSub = this.subscriptions?.find((s) => s.items?.data?.some((item) => item?.price?.lookup_key === sub.lookupKey));
- const quantity = fullAddonSub?.items?.data?.[0]?.quantity || sub.quantity || 1;
- selAddons = [{ lookupKey, quantity, desc: `${quantity} x ${subPlans[lookupKey].desc}`, price: subPlans[lookupKey].price * quantity }];
- subIds = [...subIds, ...this.getSubIds(lookupKey)];
- }
- });
-
- this.displayEdit = false;
-
- // Navigate to billing-address to set up payment
- this.store.dispatch(new StartBillingInfo({
- applicatorId: this.user?._id,
- custId: this.membership?.custId,
- selPkg,
- selAddons,
- prorateTS: DateUtils.currUTC(),
- mode: Mode.CONTINUE_TRIAL,
- subIds
- }));
-
- return; // Exit early - billing flow handles the rest
- }
-
- // No trial subscriptions need billing setup → Just update backend
-
const nonRecurSubIds = currSubs?.filter((sub) => !this.autoRenewChkbox[sub.lookupKey])?.map((sub) => sub.id) || [];
const editSubs = currSubs?.map((sub) => ({ subId: sub.id, cancelAtPeriodEnd: nonRecurSubIds.includes(sub.id) })) || [];
-
this.subSvc.editSub(editSubs).pipe(
map((subscriptions) => {
+ this.store.dispatch(new FetchLatestSubscriptionSuccess({ subscriptions, membership: this.subSvc.updateMembShip(subscriptions, this.membership) }));
this.msgSvc.addSuccessMsg($localize`:@@subEditSuccess:Subscriptions Updated Successfully`);
this.displayEdit = false;
-
- // Update component state directly from API response (no navigation needed)
- // API response has fresh cancel_at_period_end values from backend
- subscriptions?.forEach(sub => {
- // Find matching subscription by ID to get lookupKey
- const matchingSub = currSubs?.find(s => s.id === sub.id);
- if (matchingSub) {
- const lookupKey = matchingSub.lookupKey;
- // Update checkbox states: autoRenew = !cancel_at_period_end
- this.autoRenewChkbox[lookupKey] = !sub.cancel_at_period_end;
- this.autoRenewChkboxDef[lookupKey] = !sub.cancel_at_period_end;
- // CRITICAL: Also update trial checkbox states (same logic)
- // When user unchecks "Proceed with Subscription Post-Trial" and saves,
- // contTrialChkbox must also be updated so dialog shows correct state on re-open
- this.contTrialChkbox[lookupKey] = !sub.cancel_at_period_end;
- this.contTrialChkboxDef[lookupKey] = !sub.cancel_at_period_end;
- }
- });
-
- // Update this.subscriptions array for button label refresh (Trial button fix)
- // Template conditionals (*ngIf="sub.cancel_at_period_end") read from this array
- // Updating cancel_at_period_end field triggers Angular change detection
- subscriptions?.forEach(apiSub => {
- const existingSub = this.subscriptions?.find(s => s.id === apiSub.id);
- if (existingSub) {
- existingSub.cancel_at_period_end = apiSub.cancel_at_period_end;
- }
- });
-
- // CRITICAL: Update packages and addons arrays for getBtnType() to return correct button type
- // getBtnType() reads sub.cancelAtPeriodEnd (camelCase) from AGNavSubscriptionShort objects
- // Template uses getBtnType(pkg) to determine if button shows "Edit" vs "Proceed with Subscription Post-Trial"
- subscriptions?.forEach(apiSub => {
- const matchingSub = currSubs?.find(s => s.id === apiSub.id);
- if (matchingSub) {
- const lookupKey = matchingSub.lookupKey;
- // Update packages array
- const pkgToUpdate = this.packages?.find(p => p.lookupKey === lookupKey);
- if (pkgToUpdate) {
- pkgToUpdate.cancelAtPeriodEnd = apiSub.cancel_at_period_end;
- }
- // Update addons array
- const addonToUpdate = this.addons?.find(a => a.lookupKey === lookupKey);
- if (addonToUpdate) {
- addonToUpdate.cancelAtPeriodEnd = apiSub.cancel_at_period_end;
- }
- }
- });
-
- // Reload next bill amounts for subscriptions with auto-renew enabled
- // CRITICAL: Must happen AFTER backend update completes so Stripe has correct cancel_at_period_end
- subscriptions?.forEach(apiSub => {
- const matchingSub = currSubs?.find(s => s.id === apiSub.id);
- if (matchingSub) {
- const lookupKey = String(matchingSub.lookupKey);
- const isAutoRenew = !apiSub.cancel_at_period_end;
-
- if (isAutoRenew && (apiSub.status === SubStripe.ACTIVE || apiSub.status === SubStripe.TRIALING)) {
- // Reload next bill amount with updated subscription state
- this.loadNextBillAmount(lookupKey, apiSub.id);
- } else if (!isAutoRenew) {
- // Clear amount if auto-renew disabled
- delete this.nextBillAmounts[lookupKey];
- }
- }
- });
}),
catchError((err) => {
- console.error('Trial continuation error:', err);
+ console.log(err);
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.subscription));
return of(err);
})
@@ -1170,13 +361,13 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
let subIds: string[] = [];
if (isPkg) {
- const pkgSub = this.subscriptions?.find((sub) => sub.items?.data?.some((item) => this.contTrialChkbox && this.contTrialChkbox[item?.price?.lookup_key]) && sub.metadata?.type === SubType.PACKAGE);
+ const pkgSub = this.subscriptions?.find((sub) => sub.items?.data?.some((item) => this.contTrialChkbox && this.contTrialChkbox[item?.price?.lookup_key]) && sub.metadata.type === SubType.PACKAGE);
const lookupKey = pkgSub?.items?.data?.[0]?.price?.lookup_key;
selPkg = { lookupKey, desc: subPlans[lookupKey].desc, price: subPlans[lookupKey].price };
subIds = this.getSubIds(lookupKey);
}
if (isAddon) {
- const addonSub = this.subscriptions?.find((sub) => sub.items?.data?.some((item) => this.contTrialChkbox && this.contTrialChkbox[item?.price?.lookup_key]) && sub.metadata?.type === SubType.ADDON);
+ const addonSub = this.subscriptions?.find((sub) => sub.items?.data?.some((item) => this.contTrialChkbox && this.contTrialChkbox[item?.price?.lookup_key]) && sub.metadata.type === SubType.ADDON);
const lookupKey = addonSub?.items?.data?.[0]?.price?.lookup_key;
const quantity = addonSub?.items?.data?.[0]?.quantity;
selAddons = [{ lookupKey, quantity, desc: `${quantity} x ${subPlans[lookupKey].desc}`, price: subPlans[lookupKey].price * quantity }];
@@ -1192,7 +383,7 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
case EditDiaContentType.CONTINUE_TRIAL: return startBilInfoStage();
}
} catch (err) {
- console.error('Edit subscription error:', err);
+ console.log(err);
this.status = createSubStatus(SubAppErr.MGE_SUB_ERR);
}
}
@@ -1214,31 +405,12 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
getDiscount(id: string): Discount {
const sub = this.subscriptions?.find((sub) => sub.id === id);
if (!sub) return;
-
- // CRITICAL: Hide discount coupons for trial subscriptions (status='trialing')
- // Trial IS the promotion - no need to show discount/coupon labels during trial
- // Consistent with getPromoForLookupKey() behavior
- if (sub.status === SubStripe.TRIALING) {
- return null; // Hide discount for trial subscriptions
- }
-
const coupon = this.subSvc.getInvCoupon([sub.latest_invoice]);
return this.subSvc.calcAmount([sub.latest_invoice], { subscriptions: this.subscriptions, coupon }).discount;
}
contTrial(lookupKey: string, quantity: number) {
- // Guard: Don't execute if subscriptions not yet loaded
- if (this.isLoadingSubscriptions || !this.subscriptions) {
- console.warn('[contTrial] Subscriptions not yet loaded, ignoring click');
- return;
- }
-
- // Filter to only TRIALING subscriptions with cancel_at_period_end (pending post-trial decision)
- const trialSubsToDecide = this.subscriptions?.filter((sub) =>
- sub.status === SubStripe.TRIALING && sub.cancel_at_period_end
- ) || [];
-
- const isOne = trialSubsToDecide.length === 1;
+ const isOne = this.membership?.subscriptions?.filter((sub) => sub.cancelAtPeriodEnd).length === 1;
if (isOne) {
const isPkg = this.packages?.some((pkg) => pkg.lookupKey === lookupKey);
const isAddon = this.addons?.some((addon) => addon.lookupKey === lookupKey);
@@ -1304,937 +476,6 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
return this.membership?.trials?.type === GC.DAYS || this.membership?.trials?.type === GC.BYDATE;
}
- /**
- * Get formatted promo display string for subscription
- * Returns empty string if no promo active
- * Uses getPromoForLookupKey which checks subscription.promoDetails from this.subscriptions
- */
- getSubscriptionPromoDisplay(subscription: any): string {
- if (!subscription?.lookupKey) return '';
- const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package';
- const promo = this.getPromoForLookupKey(subscription.lookupKey, type);
- if (!promo) return '';
- return this.activePromoSvc.formatPromoDiscount(promo);
- }
-
- /**
- * Check if subscription has active promo
- * Uses getPromoForLookupKey which checks subscription.promoDetails from this.subscriptions
- */
- hasActivePromo(subscription: any): boolean {
- if (!subscription?.lookupKey) return false;
- const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package';
- const promo = this.getPromoForLookupKey(subscription.lookupKey, type);
- return promo !== null;
- }
-
- /**
- * Check if promo is time-limited (has expiry date)
- * Uses getPromoForLookupKey which checks subscription.promoDetails from this.subscriptions
- */
- isTimeLimitedPromo(subscription: any): boolean {
- if (!subscription?.lookupKey) return false;
- const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package';
- const promo = this.getPromoForLookupKey(subscription.lookupKey, type);
- return promo?.isTimeLimited || false;
- }
-
- /**
- * Determines if "After Promo Ends" section should be displayed
- * Shows only for auto-renewing subscriptions with time-limited promos
- *
- * - Hides for trial subscriptions (trials show after-trial pricing only)
- * - Hides for non-renewing subscriptions (subscription end date set)
- * - Hides for forever promos (isTimeLimited = false)
- * - Shows only when user will actually pay full price after promo
- *
- * @param subscription - Package or addon subscription
- * @returns true if should show "After Promo Ends" section
- */
- showAfterPromoEnds(subscription: any): boolean {
- // Find full subscription data to get status and cancel_at_period_end
- const fullSub = this.subscriptions?.find(s =>
- s.items?.data?.[0]?.price?.lookup_key === subscription?.lookupKey
- );
-
- // CRITICAL: Never show "After Promo Ends" for trial subscriptions
- // Trials already have "After Trial" pricing - showing post-promo confuses users
- if (fullSub?.status === SubStripe.TRIALING) {
- return false;
- }
-
- return subscription.promoDetails?.hasPromo &&
- subscription.promoDetails?.isTimeLimited &&
- !fullSub?.cancel_at_period_end;
- }
-
- /**
- * Get days remaining until promo expires
- * Returns null if promo is not time-limited
- * Uses getPromoForLookupKey which checks subscription.promoDetails from this.subscriptions
- */
- getPromoExpiryDays(subscription: any): number | null {
- if (!subscription?.lookupKey) return null;
- const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package';
- const promo = this.getPromoForLookupKey(subscription.lookupKey, type);
- return promo?.daysRemaining || null;
- }
-
- /**
- * Get the discount multiplier for price calculations
- * For 50% off: multiplier = 0.5 (regular price = current price / 0.5)
- * For 30% off: multiplier = 0.7 (regular price = current price / 0.7)
- * @param subscription Subscription package or addon object
- * @returns Discount multiplier (0-1) or 1 if no promo
- */
- getPromoDiscountMultiplier(subscription: any): number {
- if (!subscription?.lookupKey) return 1;
- const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package';
- const promo = this.getPromoForLookupKey(subscription.lookupKey, type);
-
- if (!promo || !promo.discountValue) return 1; // No discount
-
- const discountPercent = promo.discountType === 'percent' ? promo.discountValue : 0;
- return (100 - discountPercent) / 100; // Convert to multiplier
- }
-
- /**
- * Get formatted expiry date for display
- * Returns null if promo is not time-limited
- * Uses new promoDetails object from r948 backend enhancement
- */
- getPromoExpiryDate(subscription: any): string | null {
- return subscription.promoDetails?.expiresAt || null;
- }
-
- /**
- * Check if subscription has a renewal promo (Case 2B)
- *
- * CASE 2B: Active Subscription - Renewal Promo Offer
- * Conditions:
- * - Subscription status: ACTIVE
- * - Auto-renew: OFF (cancel_at_period_end = true)
- * - Current subscription: NO promo applied
- * - Available global promo: YES (from ActivePromoService)
- *
- * Business Goal: Re-acquisition - incentivize renewal with new promo offer
- * Display: "Renew by [date] and get 50% OFF!" (green incentive text)
- *
- * @param subscription Package or addon subscription
- * @returns True if this is a Case 2B renewal promo offer
- */
- isRenewalPromo(subscription: any): boolean {
- if (!subscription?.lookupKey) return false;
- const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package';
- const promo = this.getPromoForLookupKey(subscription.lookupKey, type);
- return promo?.isRenewalPromo === true;
- }
-
- /**
- * Get formatted renewal promo expiry date (Case 2B)
- * Returns formatted date string like '01/31/2027' for display in "Renew by XX and get 50% OFF!"
- *
- * CASE 2B: Used in renewal promo incentive message
- *
- * @param subscription Package or addon subscription
- * @returns Formatted date string or empty string
- */
- getRenewalPromoExpiryDate(subscription: any): string {
- if (!subscription?.lookupKey) return '';
- const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package';
- const promo = this.getPromoForLookupKey(subscription.lookupKey, type);
-
- if (!promo?.validUntil) return '';
-
- // Format: MM/DD/YYYY
- const expiryDate = new Date(promo.validUntil);
- const month = String(expiryDate.getMonth() + 1).padStart(2, '0');
- const day = String(expiryDate.getDate()).padStart(2, '0');
- const year = expiryDate.getFullYear();
- return `${month}/${day}/${year}`;
- }
-
- /**
- * Get promo discount display text (e.g., '50% OFF', 'FREE')
- *
- * CASE 2B: Used in renewal promo incentive message "...and get 50% OFF!"
- * CASE 3: Used for badge display on active subscriptions with applied promo
- *
- * @param subscription Package or addon subscription
- * @returns Formatted discount display string (localized)
- */
- getPromoDiscountDisplay(subscription: any): string {
- if (!subscription?.lookupKey) return '';
- const type = subscription.lookupKey.startsWith('addon_') ? 'addon' : 'package';
- const promo = this.getPromoForLookupKey(subscription.lookupKey, type);
-
- if (!promo) return '';
-
- if (promo.discountType === 'free' || promo.discountValue === 100) {
- return Labels.FREE;
- }
-
- // Check if it's a fixed discount (in cents) or percentage
- if (promo.discountType === 'fixed') {
- // Fixed discount: discountValue is in cents (e.g., 15000 = $150.00)
- const dollarAmount = (promo.discountValue / 100).toFixed(2);
- return `$${dollarAmount} ${Labels.OFF_SUFFIX}`;
- } else {
- // Percentage discount
- return `${promo.discountValue}% ${Labels.OFF_SUFFIX}`;
- }
- }
-
- /**
- * Get full renewal promo message with localized text
- *
- * Constructs message like: "Renew by 02/28/2027 and get $150.00 OFF!"
- * All text parts are localized for multi-language support
- *
- * @param subscription Package or addon subscription
- * @returns Formatted, localized renewal promo message
- */
- getRenewalPromoMessage(subscription: any): string {
- const date = this.getRenewalPromoExpiryDate(subscription);
- const discount = this.getPromoDiscountDisplay(subscription);
-
- if (!date || !discount) return '';
-
- // Construct: "Renew by {date} and get {discount}!"
- return `${Labels.RENEW_BY_PREFIX} ${date} ${Labels.AND_GET} ${discount}!`;
- }
-
- /**
- * Generates comprehensive promo description using promoDetails object
- * Uses r962 backend enhancements (durationInMonths, discountEndsAt, daysUntilDiscountEnds)
- *
- * - Replaces simple badge with concise promo information
- * - Shows discount amount and duration in natural format (matches Stripe)
- * - Format: "$150.00 OFF for 12 months" (instead of "Ends in 365 days")
- *
- * @param subscription - Package or addon subscription
- * @returns Concise promo description string
- */
- getPromoDescription(subscription: any): string {
- const promo = subscription.promoDetails;
- if (!promo?.hasPromo) return '';
-
- // Start with discount amount (e.g., "$150.00 OFF", "FREE")
- let desc = promo.discountDisplay;
-
- // Forever promos: Always show "until subscription ends"
- // Ignore isTimeLimited flag as it may reflect coupon redeem_by deadline,
- // not the discount duration after application
- if (promo.duration === 'forever') {
- desc += ` • ${Labels.PROMO_UNTIL_SUBSCRIPTION_ENDS}`;
- return desc;
- }
-
- // Repeating promos: Add duration information if time-limited
- if (promo.isTimeLimited) {
- if (promo.durationInMonths) {
- // Use months when available (more intuitive than days)
- const months = promo.durationInMonths;
- const unit = months === 1 ? Labels.PROMO_MONTH : Labels.PROMO_MONTHS;
- desc += ` ${Labels.PROMO_FOR} ${months} ${unit}`;
- } else if (promo.daysUntilDiscountEnds) {
- // Fallback to days if durationInMonths not available
- desc += ` • ${Labels.PROMO_EXPIRES_IN} ${promo.daysUntilDiscountEnds} ${Labels.PROMO_DAYS}`;
- }
- } else {
- // No expiration
- desc += ` • ${Labels.PROMO_NO_EXPIRATION}`;
- }
-
- return desc;
- }
-
- /**
- * Check if promo should be displayed based on Case 2 requirements
- *
- * Case 2A (Retention): Subscription HAS promo + auto-renew OFF
- * - Message: "Your 50% OFF expires in X days - renew to keep it!"
- * - Shows expiry warning to prevent churn
- *
- * Case 2B (Re-acquisition): Subscription does NOT have promo + auto-renew OFF + available global promo
- * - Message: "Renew by 01/31 and get 50% OFF!"
- * - Shows available promo to incentivize renewal
- *
- * See: docs/current_work/.../2026-01-26-15-35-promo-display-cases-analysis.md
- */
- shouldShowPromoCase2(subscription: any): boolean {
- // Must have auto-renew OFF (cancel_at_period_end = true)
- const lookupKey = subscription.lookupKey;
- const hasAutoRenew = this.autoRenewChkbox?.[lookupKey];
- if (hasAutoRenew) {
- return false; // Auto-renew is ON, don't show promo
- }
-
- // SCENARIO A: Subscription HAS promo applied (Case 2A - Retention)
- if (this.hasActivePromo(subscription)) {
- // Show expiry warning: "Your 50% OFF expires in X days"
- if (!this.isTimeLimitedPromo(subscription)) {
- return false; // Forever coupon - not relevant for expiry warning
- }
-
- const promoExpiresAt = subscription.promoDetails?.expiresAt;
- const subscriptionPeriodEnd = subscription.periodEnd;
-
- if (!promoExpiresAt || !subscriptionPeriodEnd) {
- return false; // Missing required dates
- }
-
- const promoExpiryTimestamp = new Date(promoExpiresAt).getTime();
- const periodEndTimestamp = subscriptionPeriodEnd * 1000;
-
- // Show promo when it expires AFTER subscription period ends
- return promoExpiryTimestamp > periodEndTimestamp;
- }
-
- // SCENARIO B: Subscription does NOT have promo (Case 2B - Re-acquisition)
- // Check if there's an available global promo to incentivize renewal
- const availablePromo = this.getAvailablePromo(subscription);
-
- if (availablePromo && availablePromo.validUntil) {
- const promoExpiryTimestamp = new Date(availablePromo.validUntil).getTime();
- const periodEndTimestamp = subscription.periodEnd * 1000;
-
- // Show available promo when it would apply beyond current subscription period
- return promoExpiryTimestamp > periodEndTimestamp;
- }
-
- return false;
- }
-
- /**
- * Get available global promo for subscription type
- * Used in Case 2B to show renewal incentive
- *
- * For packages, checks:
- * 1. Exact match by lookupKey (e.g., 'ess_1')
- * 2. Type-only promo for all packages ('package_all')
- *
- * For addons, checks:
- * 1. Exact match by lookupKey (e.g., 'addon_1')
- * 2. Type-only promo for all addons ('addon_all')
- */
- getAvailablePromo(subscription: any): ActivePromo | null {
- if (!this.activePromos || this.activePromos.size === 0) {
- return null;
- }
-
- const lookupKey = subscription.lookupKey;
-
- // Determine type based on lookupKey pattern
- // Packages: ess_*, ent_*
- // Addons: addon_*
- const isAddon = lookupKey?.startsWith('addon_');
- const type = isAddon ? 'addon' : 'package';
-
- // Try exact match first
- const exactMatch = this.activePromos.get(lookupKey);
- if (exactMatch) {
- return exactMatch;
- }
-
- // Try type-only match
- const typeOnlyKey = `${type}_all`;
- const typeMatch = this.activePromos.get(typeOnlyKey);
- if (typeMatch) {
- return typeMatch;
- }
-
- return null;
- }
-
- /**
- * Get expiry date timestamp for available promo (Case 2B)
- * Returns Unix timestamp for use with tsToDate pipe for proper localization
- */
- getAvailablePromoExpiry(subscription: any): number | null {
- const promo = this.getAvailablePromo(subscription);
- if (!promo?.validUntil) return null;
- return new Date(promo.validUntil).getTime() / 1000; // Convert to Unix timestamp
- }
-
- // ============================================================================
- // ENHANCED PROMO DISPLAY LOGIC (8 Cases)
- // ============================================================================
-
- /**
- * Determine which promo display template to use (Cases 1-8)
- * Returns case number and urgency flag for conditional styling
- *
- * Case 1: Permanent discount (forever, no expiry)
- * Case 2: Forever with redemption deadline
- * Case 3: Schedule-managed forever (ending soon)
- * Case 4: Repeating duration (standard)
- * Case 5: Repeating with urgency (<30 days)
- * Case 6: One-time discount (already applied)
- * Case 7: FREE promo (100% discount)
- * Case 8: No active promo
- */
- getPromoDisplayTemplate(subscription: any): { case: number; isUrgent: boolean } {
- const promo = subscription?.promoDetails;
-
- if (!promo?.hasPromo) {
- return { case: 8, isUrgent: false }; // No promo
- }
-
- // Case 6: One-time already applied
- if (promo.duration === 'once' && promo.discountEndsAt === 'applied') {
- return { case: 6, isUrgent: false };
- }
-
- // Forever duration cases (1, 2, 3)
- if (promo.duration === 'forever') {
- // Case 1: Permanent (no expiry)
- if (!promo.expiresAt && !promo.discountEndsAt) {
- return { case: 1, isUrgent: false };
- }
-
- // Case 2: Forever with redeem_by deadline
- if (promo.expiresAt && promo.discountEndsAt && promo.expiresAt === promo.discountEndsAt) {
- return { case: 2, isUrgent: false };
- }
-
- // Case 3: Schedule-managed forever (ending soon)
- if (promo.expiresAt && promo.daysRemaining !== null) {
- const isUrgent = promo.daysRemaining < 30;
- return { case: 3, isUrgent };
- }
- }
-
- // Repeating duration cases (4, 5)
- if (promo.duration === 'repeating') {
- const daysRemaining = promo.daysUntilDiscountEnds ?? 0;
- const isUrgent = daysRemaining < 30;
-
- if (isUrgent) {
- return { case: 5, isUrgent: true }; // Urgent
- } else {
- return { case: 4, isUrgent: false }; // Standard
- }
- }
-
- // Case 7: Permanent FREE promo (100% discount with NO time limits)
- // Only applies to promos that are truly permanent (no expiry, no discount end date)
- if ((promo.percentOff === 100 || promo.discountDisplay?.toUpperCase() === 'FREE') &&
- !promo.expiresAt && !promo.discountEndsAt &&
- (!promo.daysRemaining || promo.daysRemaining === 0) &&
- (!promo.daysUntilDiscountEnds || promo.daysUntilDiscountEnds === 0)) {
- return { case: 7, isUrgent: false };
- }
-
- // Default fallback
- return { case: 8, isUrgent: false };
- }
-
- /**
- * Check if promo is in urgent state (<30 days remaining)
- * Used for conditional styling (amber/red backgrounds)
- */
- isPromoUrgent(subscription: any): boolean {
- const template = this.getPromoDisplayTemplate(subscription);
- return template.isUrgent;
- }
-
- /**
- * Get promo expiry text for display
- * Returns formatted date string or null if no expiry
- *
- * Examples:
- * - "Valid until: Dec 31, 2026"
- * - "Promo ends: Jun 30, 2026"
- * - null (for permanent promos or already-redeemed deadlines)
- *
- * Note: Uses UTC timezone to avoid date shifting issues
- * (expiresAt/discountEndsAt represent calendar dates, not specific moments)
- *
- * UX Decision: Case 2 (forever promo with redeem_by) hides expiry date
- * because the redemption deadline is irrelevant once user has the promo locked in.
- * Showing "Valid until: [past date]" creates false anxiety about discount ending.
- */
- getPromoExpiryText(subscription: any): string | null {
- const promo = subscription?.promoDetails;
- if (!promo?.hasPromo) return null;
-
- const template = this.getPromoDisplayTemplate(subscription);
-
- // Case 2: Forever with redeem_by deadline - hide the date (already locked in)
- // Don't show redemption deadlines for permanent discounts user already has
- if (template.case === 2) {
- return null;
- }
-
- // Case 3: Schedule-managed forever (actual end date) - show the date
- if (template.case === 3) {
- if (promo.expiresAt) {
- const date = new Date(promo.expiresAt);
- return date.toLocaleDateString('en-US', { timeZone: 'UTC' });
- }
- }
-
- if (template.case === 4 || template.case === 5) {
- // Repeating standard or urgent.
- // discountEndsAt is the first billing date WITHOUT the discount, so the last active
- // discount day is the day before — subtract 1 day for display.
- if (promo.discountEndsAt && promo.discountEndsAt !== 'applied') {
- const date = new Date(promo.discountEndsAt);
- date.setUTCDate(date.getUTCDate() - 1);
- return date.toLocaleDateString('en-US', { timeZone: 'UTC' });
- }
- }
-
- return null;
- }
-
- /**
- * Get the appropriate label text for promo expiry
- * Returns the label that should be shown based on promo type:
- * - null for case 2 (forever with redeem_by - hides irrelevant redemption deadline)
- * - "Expires:" for case 3 (schedule-managed forever with actual end date)
- * - "Discount ends:" for repeating promos (cases 4, 5)
- * - null if no expiry info to display
- */
- getPromoExpiryLabel(subscription: any): string | null {
- const promo = subscription?.promoDetails;
- if (!promo?.hasPromo) return null;
-
- const template = this.getPromoDisplayTemplate(subscription);
-
- // Cases 2, 3: Forever promos show "Expires:" (but Case 2 returns null text, so won't display)
- if (template.case === 2 || template.case === 3) {
- return this.getPromoExpiryText(subscription) ? Labels.PROMO_VALID_UNTIL_COLON : null;
- }
-
- // Cases 4, 5: Repeating promos show "Discount ends:"
- if (template.case === 4 || template.case === 5) {
- return this.getPromoExpiryText(subscription) ? Labels.PROMO_DISCOUNT_ENDS : null;
- }
-
- return null;
- }
-
- /**
- * Get promo duration text with days remaining
- * Used for urgency messaging
- *
- * Examples:
- * - "6 months (177 days remaining)"
- * - "Only 25 days remaining!"
- * - null (for permanent or applied promos)
- */
- getPromoDurationText(subscription: any): string | null {
- const promo = subscription?.promoDetails;
- if (!promo?.hasPromo) return null;
-
- const template = this.getPromoDisplayTemplate(subscription);
-
- // Case 3: Schedule-managed forever
- if (template.case === 3 && promo.daysRemaining !== null) {
- if (template.isUrgent) {
- return `${Labels.PROMO_ONLY} ${promo.daysRemaining} ${Labels.PROMO_DAYS_REMAINING_SUFFIX}!`;
- } else {
- return `${promo.daysRemaining} ${Labels.PROMO_DAYS_UNTIL_EXPIRES}`;
- }
- }
-
- // Case 4 & 5: Repeating promos
- if ((template.case === 4 || template.case === 5) && promo.durationInMonths) {
- const months = promo.durationInMonths;
- const unit = months === 1 ? Labels.PROMO_MONTH : Labels.PROMO_MONTHS;
- // daysUntilDiscountEnds counts to the first billing WITHOUT the discount;
- // subtract 1 to show days until the last active discount day.
- const daysLeft = promo.daysUntilDiscountEnds !== null ? promo.daysUntilDiscountEnds - 1 : null;
- const daysText = daysLeft !== null
- ? ` (${daysLeft} ${Labels.PROMO_DAYS_REMAINING_SUFFIX})`
- : '';
-
- if (template.isUrgent && daysLeft !== null) {
- return `${Labels.PROMO_ONLY} ${daysLeft} ${Labels.PROMO_DAYS_REMAINING_SUFFIX}!`;
- } else {
- return `${months} ${unit}${daysText}`;
- }
- }
-
- return null;
- }
-
- /**
- * Get promo type icon based on case
- * Used for visual indicators
- *
- * Returns PrimeIcons class names (only using icons available in theme-green.min.css):
- * - "pi-check" - Permanent discount / Already applied
- * - "pi-calendar" - Time-limited
- * - "pi-exclamation-triangle" - Urgent (ending soon)
- * - "pi-star" - FREE promo
- */
- getPromoTypeIcon(subscription: any): string {
- const template = this.getPromoDisplayTemplate(subscription);
-
- switch (template.case) {
- case 1: return 'pi-check'; // Permanent
- case 2: return 'pi-calendar'; // Forever with deadline
- case 3: return template.isUrgent ? 'pi-exclamation-triangle' : 'pi-calendar'; // Schedule-managed
- case 4: return 'pi-calendar'; // Repeating standard
- case 5: return 'pi-exclamation-triangle'; // Repeating urgent
- case 6: return 'pi-check'; // Once applied
- case 7: return 'pi-tag'; // FREE - Testing with tag icon
- default: return '';
- }
- }
-
- /**
- * Get promo type label for accessibility and clarity
- *
- * Returns:
- * - "Permanent Discount"
- * - "Time-Limited Offer"
- * - "Ending Soon"
- * - "One-Time Discount"
- * - "FREE Promotion"
- */
- getPromoTypeLabel(subscription: any): string {
- const template = this.getPromoDisplayTemplate(subscription);
-
- switch (template.case) {
- case 1: return Labels.PROMO_TYPE_PERMANENT;
- case 2: return Labels.PROMO_TYPE_TIME_LIMITED;
- case 3: return template.isUrgent ? Labels.PROMO_TYPE_ENDING_SOON : Labels.PROMO_TYPE_LIMITED_TIME;
- case 4: return Labels.PROMO_TYPE_PROMOTIONAL_PERIOD;
- case 5: return Labels.PROMO_TYPE_ENDING_SOON;
- case 6: return Labels.PROMO_TYPE_ONE_TIME;
- case 7: return Labels.PROMO_TYPE_FREE;
- default: return '';
- }
- }
-
- // ============================================================================
- // COMPACT VERTICAL LIST - BADGE CONFIGURATION GETTERS
- // ============================================================================
-
- /**
- * Get discount badge configuration for compact vertical list header
- * Shows promotional discount (e.g., "50% OFF")
- */
- getDiscountBadgeConfig(subscription: any): any {
- return {
- text: this.getSubscriptionPromoDisplay(subscription),
- type: 'promo-discount',
- icon: 'pi pi-tag',
- size: 'sm'
- };
- }
-
- /**
- * Get status badge configuration for compact vertical list header
- * Shows subscription status (Active, Trialing, etc.)
- */
- getStatusBadgeConfig(subscription: any): any {
- const statusMap = {
- [SubStripe.ACTIVE]: { text: Labels.SUBSCRIPTION_STATUS_ACTIVE, type: 'status-active', icon: 'pi pi-check' },
- [SubStripe.TRIALING]: { text: Labels.SUBSCRIPTION_STATUS_TRIAL, type: 'status-pending', icon: 'pi pi-calendar' },
- [SubStripe.PAST_DUE]: { text: Labels.SUBSCRIPTION_STATUS_PAST_DUE, type: 'status-error', icon: 'pi pi-exclamation-triangle' },
- [SubStripe.CANCELED]: { text: Labels.SUBSCRIPTION_STATUS_CANCELED, type: 'status-inactive', icon: 'pi pi-times' },
- [SubStripe.INCOMPLETE]: { text: Labels.SUBSCRIPTION_STATUS_INCOMPLETE, type: 'status-pending', icon: 'pi pi-ellipsis-h' }
- };
-
- const config = statusMap[subscription.status] || { text: subscription.status, type: 'status-inactive', icon: 'pi pi-info-circle' };
- return {
- ...config,
- size: 'sm'
- };
- }
-
- /**
- * Get regular price (price before discount)
- * Returns the base price from subPlans (fullPkg.price is already the regular price)
- * For ESS_1 with 50% OFF: base=$995, discount=50%, current=$497.50
- */
- getRegularPrice(subscription: any, fullPkg: any): number {
- // fullPkg.price is already the BASE/REGULAR price from subPlans
- return fullPkg.price / 100;
- }
-
- /**
- * Get current discounted price user is actually paying
- * Uses latest_invoice.total_excluding_tax as source of truth for actual charged amount
- * Fallback to fullPkg.price if invoice data not available
- *
- * For ESS_1 with $150 OFF:
- * - Base: $995.00 (99500 cents)
- * - Invoice total_excluding_tax: $845.00 (84500 cents) ✅ ACTUAL PRICE
- */
- getCurrentPrice(subscription: any, fullPkg: any): number {
- // Find full subscription data from subscriptions array (has invoice data)
- // StripeSubscription has lookup_key at items.data[0].price.lookup_key
- const fullSub = this.subscriptions?.find(s =>
- s.items?.data?.[0]?.price?.lookup_key === subscription?.lookupKey
- );
-
- // Use invoice data as source of truth (already includes discount)
- if (fullSub?.latest_invoice?.total_excluding_tax !== undefined) {
- return fullSub.latest_invoice.total_excluding_tax / 100;
- }
-
- // Fallback to fullPkg price if invoice not available
- return (fullPkg?.price || 0) / 100;
- }
-
- /**
- * Calculate actual savings amount from promotional discount
- * Uses latest_invoice.total_discount_amounts as source of truth
- *
- * For ESS_1 with $150 OFF:
- * - Discount applied: $150.00 (15000 cents from invoice)
- * For ADDON_1 with FREE (100% OFF):
- * - Discount applied: $49.95 (4995 cents - full price)
- */
- getSavingsAmount(subscription: any, fullPkg: any): number {
- // Find full subscription data from subscriptions array (has invoice data)
- const fullSub = this.subscriptions?.find(s =>
- s.items?.data?.[0]?.price?.lookup_key === subscription?.lookupKey
- );
-
- // Cast to any to access Stripe fields not in our TypeScript interface
- const invoice: any = fullSub?.latest_invoice;
-
- // Use invoice discount amounts as source of truth
- if (invoice?.total_discount_amounts?.length) {
- // Sum all discount amounts (usually just one)
- const totalDiscount = invoice.total_discount_amounts
- .reduce((sum, discount) => sum + discount.amount, 0);
- return totalDiscount / 100;
- }
-
- // No discount applied
- return 0;
- }
-
- /**
- * Get formatted billing cycle text
- * Maps subscription interval to human-readable text
- */
- getBillingCycleText(subscription: any): string {
- const intervalMap = {
- 'year': 'Yearly',
- 'month': 'Monthly',
- 'week': 'Weekly',
- 'day': 'Daily'
- };
- return intervalMap[subscription.interval] || subscription.interval;
- }
-
- // ============================================================================
- // CASE 2C: TRIAL WITH PROMO - POST-TRIAL CONTINUATION
- // ============================================================================
- //
- // CONDITIONS:
- // - Subscription status: TRIALING
- // - User selected "Proceed with Subscription Post-Trial" (cancel_at_period_end = false)
- // - Active promo available (promoDetails.hasPromo = true)
- //
- // BUSINESS GOAL: Price Confirmation - show confirmed discounted price after trial
- // DISPLAY: "After Trial: $497.50" with strikethrough regular price
- // ============================================================================
-
- /**
- * Calculate after-trial price with promo discount applied
- * Used for Case 2C: Trial subscription with active global promo
- * Handles both amountOff and percentOff from promoDetails
- * @param subscription - AGNavSubscriptionShort with trialEnd and promoDetails
- * @returns Discounted price string (e.g., "$497.50/year" or "$845.00/year")
- */
- getAfterTrialPrice(subscription: AGNavSubscriptionShort): string {
- if (!subscription?.trialEnd || !subscription?.promoDetails?.hasPromo) {
- // No trial or no promo - return base price from subPlans
- const fullPkg = subPlans[subscription?.lookupKey];
- if (!fullPkg) return '';
- return this.subSvc.formatCurrency(fullPkg.price);
- }
-
- const fullPkg = subPlans[subscription.lookupKey];
- if (!fullPkg) return '';
-
- const basePrice = fullPkg.price; // Price in cents
- let discountedPrice = basePrice;
-
- // Handle amountOff (e.g., $150.00 OFF = 15000 cents)
- if (subscription.promoDetails.amountOff) {
- discountedPrice = basePrice - subscription.promoDetails.amountOff;
- }
- // Handle percentOff (e.g., 50% OFF)
- else if (subscription.promoDetails.percentOff) {
- discountedPrice = basePrice * (1 - subscription.promoDetails.percentOff / 100);
- }
-
- return this.subSvc.formatCurrency(discountedPrice);
- }
-
- /**
- * Parse discount percentage from display string
- * Examples: "50% OFF" → 50, "30% OFF" → 30
- * @param discountDisplay - Discount display string from promoDetails
- * @returns Numeric discount percentage
- */
- private parseDiscountPercent(discountDisplay: string): number {
- if (!discountDisplay) return 0;
- const match = discountDisplay.match(/(\d+)\s*%/);
- return match ? parseInt(match[1], 10) : 0;
- }
-
- /**
- * Get badge configuration for promo display
- * Used for Case 2C trial promo badge
- * @param promoDetails - Promo information from subscription
- * @returns BadgeConfig for agm-badge component or null if no promo
- */
- getTrialPromoBadgeConfig(promoDetails: any): BadgeConfig | null {
- if (!promoDetails?.hasPromo) return null;
-
- return {
- text: promoDetails.discountDisplay,
- type: BadgeType.PROMO_DISCOUNT,
- icon: 'pi-tag',
- size: BadgeSize.SMALL
- };
- }
-
- /**
- * Check if subscription is in trial period with active promo
- *
- * CASE 2C & 2D: Returns true for both cases (with/without post-trial continuation)
- * CASE 2A: Returns false (trial without promo) - shows basic trial display
- *
- * Used with isTrialWithoutContinuation() to differentiate:
- * - isTrialWithPromo() && !isTrialWithoutContinuation() → Case 2C
- * - isTrialWithoutContinuation() → Case 2D
- * - !isTrialWithPromo() → Case 2A
- *
- * @param subscription - AGNavSubscriptionShort to check
- * @returns True if trial + promo applies
- */
- isTrialWithPromo(subscription: AGNavSubscriptionShort): boolean {
- return subscription?.status === SubStripe.TRIALING &&
- !!subscription?.trialEnd &&
- !!subscription?.promoDetails?.hasPromo;
- }
-
- // ============================================================================
- // CASE 2D: TRIAL WITH PROMO - NO POST-TRIAL CONTINUATION
- // ============================================================================
- //
- // CONDITIONS:
- // - Subscription status: TRIALING
- // - User did NOT select "Proceed with Subscription Post-Trial" (cancel_at_period_end = true)
- // - Available promo exists (promoDetails.hasPromo = true)
- //
- // BUSINESS GOAL: Incentive Offer - encourage renewal by highlighting available discount
- // DISPLAY: "Renew by [date] and get 50% OFF!" (green incentive text, NO strikethrough)
- //
- // KEY DIFFERENCE FROM CASE 2C:
- // - Case 2C: Shows confirmed price user WILL pay ("After Trial: $497.50")
- // - Case 2D: Shows incentive offer user CAN get ("Renew by ... and get 50% OFF!")
- // ============================================================================
-
- /**
- * Check if trial subscription will NOT continue after trial
- * Used for Case 2D: Show incentive offer instead of confirmed pricing
- *
- * Returns true when:
- * 1. Subscription status is TRIALING
- * 2. User has NOT selected "Proceed with Subscription Post-Trial" (cancel_at_period_end = true)
- * 3. Active promo is available
- *
- * Business Logic:
- * - During trial creation, if user does NOT check "Proceed", Stripe sets cancel_at_period_end=true
- * - This means subscription will cancel at trial end unless user manually renews
- * - We should incentivize renewal by showing available promo offer (like Case 2B)
- *
- * @param subscription - AGNavSubscriptionShort to check
- * @returns True if trial will NOT continue and has promo
- */
- isTrialWithoutContinuation(subscription: AGNavSubscriptionShort): boolean {
- // Must be trialing status
- if (subscription?.status !== SubStripe.TRIALING) {
- return false;
- }
-
- // Must have cancel_at_period_end = true (no continuation)
- if (!subscription?.cancelAtPeriodEnd) {
- return false;
- }
-
- // Must have available promo to offer as incentive
- if (!subscription?.promoDetails?.hasPromo) {
- return false;
- }
-
- return true;
- }
-
- /**
- * Format trial end date for display
- * @param trialEnd - UNIX timestamp from subscription
- * @returns Formatted date string (e.g., "2/2/2026")
- */
- formatTrialEndDate(trialEnd: number): string {
- if (!trialEnd) return '';
- const date = new Date(trialEnd * 1000);
- return date.toLocaleDateString();
- }
-
- /**
- * Get max vehicles from subscription price metadata (includes custom limits)
- * Reads from full StripeSubscription's price.metadata.maxVehicles which has custom limits applied by backend
- * Falls back to fullPkg.maxVehicles, then to subscription quantity (for addons)
- *
- * @param agNavSub - AGNavSubscriptionShort from packages/addons array
- * @param fullPkg - Package details from subPlans (via subPkg pipe)
- * @returns Max vehicles count as number
- */
- getMaxVehicles(agNavSub: AGNavSubscriptionShort, fullPkg: any): number {
- // Find the full StripeSubscription by ID (has complete items.data structure)
- const fullSub = this.subscriptions?.find(sub => sub.id === agNavSub.id);
-
- // Try to get from subscription's price metadata first (includes custom limits)
- if (fullSub?.items?.data?.[0]?.price?.metadata?.maxVehicles) {
- return parseInt(fullSub.items.data[0].price.metadata.maxVehicles, 10);
- }
-
- // Fallback to price catalog maxVehicles
- if (fullPkg?.maxVehicles) {
- return fullPkg.maxVehicles;
- }
-
- // Final fallback to subscription quantity (for addons where quantity = vehicle count)
- return agNavSub?.quantity || 0;
- }
-
- /**
- * Get max acres from subscription price metadata (includes custom limits)
- * Reads from full StripeSubscription's price.metadata.maxAcres which has custom limits applied by backend
- * Falls back to fullPkg.maxAcres from price catalog
- *
- * @param agNavSub - AGNavSubscriptionShort from packages array
- * @param fullPkg - Package details from subPlans (via subPkg pipe)
- * @returns Max acres count as number (0 = unlimited)
- */
- getMaxAcres(agNavSub: AGNavSubscriptionShort, fullPkg: any): number {
- // Find the full StripeSubscription by ID (has complete items.data structure)
- const fullSub = this.subscriptions?.find(sub => sub.id === agNavSub.id);
-
- // Try to get from subscription's price metadata first (includes custom limits)
- if (fullSub?.items?.data?.[0]?.price?.metadata?.maxAcres) {
- return parseInt(fullSub.items.data[0].price.metadata.maxAcres, 10);
- }
-
- // Fallback to price catalog maxAcres
- return fullPkg?.maxAcres || 0;
- }
-
ngOnDestroy(): void {
this.store.dispatch(new CancelPollSubscription());
super.ngOnDestroy();
diff --git a/Development/client/src/app/profile/payment-detail/payment-detail.component.html b/Development/client/src/app/profile/payment-detail/payment-detail.component.html
index 50b4060..de59e51 100644
--- a/Development/client/src/app/profile/payment-detail/payment-detail.component.html
+++ b/Development/client/src/app/profile/payment-detail/payment-detail.component.html
@@ -1,6 +1,6 @@
- Create and manage global promotional campaigns which apply coupons for subscription packages and add-ons with specific eligibility criteria and validity periods.
-