diff --git a/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.ts b/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.ts
index a40dce5..80d1fc0 100644
--- a/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.ts
+++ b/Development/client/src/app/invoices/invoice-detail/invoice-detail.component.ts
@@ -170,18 +170,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
...c,
...this.billToListPriceObject(c)
}));
-
- // Track invoice viewed
- this.gaSvc.trackInvoiceViewed({
- invoice_id: invoice._id,
- invoice_status: invoice.status,
- invoice_amount: this.calculateInvoiceAmount(invoice),
- view_source: 'direct_link',
- client_id: invoice.clients?.[0]?.billTo?._id || 'unknown',
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
} else {
this.goBack();
}
@@ -284,20 +272,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
delete payload.amountDue;
this.invoiceSvc.createLogPayment(payload).subscribe(log => {
if (log) {
- // Track payment logging
- this.gaSvc.trackInvoicePaymentLogged({
- invoice_id: this.invoice._id,
- payment_amount: this.logPaymentForm.amount || 0,
- payment_method: this.gaHelpers.mapPaymentMethod(this.logPaymentForm.paymentMethod),
- payment_date: this.logPaymentForm.paymentDate?.toISOString().split('T')[0] || new Date().toISOString().split('T')[0],
- remaining_balance: this.gaHelpers.calculateRemainingBalance(this.invoice),
- days_to_payment: this.gaHelpers.calculateDaysToPayment(new Date(this.invoice.openDate), this.logPaymentForm.paymentDate),
- payment_reference: this.gaHelpers.generatePaymentReference(this.invoice?.code || 'INV'),
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
-
this.logPaymentDlg = false;
this.msgSvc.addSuccessMsg($localize`:@@logPaymentSucceeded: Create log payment succeeded`);
this.fetchInvoiceDetail(this.invoice._id);
@@ -331,18 +305,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
(res) => {
try {
saveAs(res, `Agmission_invoice_${this.invoice.code}_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.csv`);
-
- // Track invoice export
- this.gaSvc.trackInvoiceExported({
- invoice_id: this.invoice._id,
- export_format: 'csv',
- invoice_amount: this.calculateInvoiceAmount(this.invoice),
- export_method: 'single',
- includes_job_details: true,
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
} catch (error) {
alert('Sorry. Your browser does not support this feature !');
}
@@ -357,18 +319,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
(res) => {
try {
saveAs(res, `Agmission_invoice_${this.invoice.code}_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.iif`);
-
- // Track invoice export
- this.gaSvc.trackInvoiceExported({
- invoice_id: this.invoice._id,
- export_format: 'iif',
- invoice_amount: this.calculateInvoiceAmount(this.invoice),
- export_method: 'single',
- includes_job_details: true,
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
} catch (error) {
alert('Sorry. Your browser does not support this feature !');
}
@@ -386,14 +336,6 @@ export class InvoiceDetailComponent extends BaseComp implements OnInit, OnDestro
DomUtils.hide(elts)
}
- private calculateInvoiceAmount(invoice: any): number {
- if (!invoice) return 0;
- if (invoice.status == invoiceStatus.VOID) {
- return 0;
- }
- return Utils.arraySum(invoice?.clients?.map(client => this.billToListPriceObject(client).total) || [0]);
- }
-
ngOnDestroy() {
super.ngOnDestroy();
}
diff --git a/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.ts b/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.ts
index 8c33b25..88f3e35 100644
--- a/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.ts
+++ b/Development/client/src/app/invoices/invoice-edit/invoice-edit.component.ts
@@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { BaseComp } from '@app/shared/base/base.component';
import * as invoiceAction from '../actions/invoice.actions';
import { ActivatedRoute } from '@angular/router';
-import { globals, invoiceStatus, RoleIds } from '@app/shared/global';
+import { globals, invoiceStatus } from '@app/shared/global';
import { Invoice } from '@app/invoices/models/invoice.model';
import { InvoiceService } from '@app/domain/services/invoice.service';
import { SelectItem } from 'primeng/api';
@@ -16,7 +16,6 @@ import { filter, map, switchMap } from 'rxjs/operators';
import { DomUtils } from '@app/shared/dom-util';
import { MultiSelect } from 'primeng/multiselect';
import { ClientService } from '@app/domain/services/client.service';
-import { GAService } from '@app/shared/ga.service';
@Component({
selector: 'agm-invoice-edit',
@@ -846,21 +845,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
private handleLogPaymentSuccess(logs: any[], invoice: Invoice) {
this.logPaymentList = logs;
this.logPaymentDlg = false;
-
- // Track payment logging
- this.gaSvc.trackInvoicePaymentLogged({
- invoice_id: this.invoice._id,
- payment_amount: this.logPaymentForm.amount || 0,
- payment_method: this.gaHelpers.mapPaymentMethod(this.logPaymentForm.paymentMethod),
- payment_date: this.logPaymentForm.paymentDate?.toISOString().split('T')[0] || new Date().toISOString().split('T')[0],
- remaining_balance: this.gaHelpers.calculateRemainingBalance(invoice),
- days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, this.logPaymentForm.paymentDate),
- payment_reference: this.gaHelpers.generatePaymentReference(this.invoice?.code),
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
-
this.store.dispatch(new invoiceAction.FetchSuccess([invoice]));
}
@@ -878,20 +862,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
_id: this.invoice._id,
status: invoiceStatus.OPEN
};
-
- // Track invoice status change
- this.gaSvc.trackInvoiceStatusChanged({
- invoice_id: this.invoice._id,
- old_status: this.invoice.status as any,
- new_status: invoiceStatus.OPEN as any,
- status_change_reason: 'user_action',
- total_amount: this.totalTotal || 0,
- days_in_previous_status: this.calculateDaysInStatus(),
- user_id: this.authSvc.user?._id,
- user_role: this.getUserRole(),
- platform: 'web'
- });
-
this.store.dispatch(new invoiceAction.Update(payload));
}
@@ -910,36 +880,8 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
const payload = this.prepareInvoicePayload();
if (this.isNew) {
this.store.dispatch(new invoiceAction.Create(payload));
-
- // Track invoice creation
- this.gaSvc.trackInvoiceCreated({
- invoice_id: payload._id || 'new',
- client_id: this.selectedClients?.[0]?.billTo?._id || 'unknown',
- total_amount: this.totalTotal || 0,
- currency: 'USD',
- job_count: this.selectedJobs?.length || 0,
- creation_method: 'manual',
- due_date_days: this.calculateDueDateDays(payload.dueDate),
- payment_terms: String(payload.paymentTerm) || 'net_30',
- user_id: this.authSvc.user?._id,
- user_role: this.getUserRole(),
- platform: 'web'
- });
} else {
this.store.dispatch(new invoiceAction.Update(payload));
-
- // Track invoice update
- this.gaSvc.trackInvoiceUpdated({
- invoice_id: this.invoice._id,
- fields_modified: this.getModifiedFields(),
- amount_change: this.calculateAmountChange(),
- previous_status: this._orgInvoice?.status,
- current_status: this.invoice.status,
- modification_type: this.determineModificationType(),
- user_id: this.authSvc.user?._id,
- user_role: this.getUserRole(),
- platform: 'web'
- });
}
}
@@ -1035,22 +977,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
if (invoice) {
paymentPayload = paymentPayload.map(i => ({ ...i, invoiceId: invoice._id }));
this.invoiceSvc.createListLogPayment(paymentPayload).subscribe(res => {
- // Track bulk payment logging for new invoice
- paymentPayload.forEach((payment, index) => {
- this.gaSvc.trackInvoicePaymentLogged({
- invoice_id: invoice._id,
- payment_amount: parseFloat(payment.amount) || 0,
- payment_method: this.gaHelpers.mapPaymentMethod(payment.paymentMethod),
- payment_date: payment.paymentDate?.toISOString?.()?.split('T')[0] || new Date().toISOString().split('T')[0],
- remaining_balance: 0, // Full payment scenario
- days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, payment.paymentDate),
- payment_reference: `${this.gaHelpers.generatePaymentReference(this.invoice?.code)}-${index + 1}`,
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
- });
-
this.store.dispatch(new invoiceAction.CreateSuccess(invoice));
}, err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', $localize`:@@logPayment:Log Payment`));
@@ -1066,22 +992,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
if (invoice) {
paymentPayload = paymentPayload.map(i => ({ ...i, invoiceId: invoice._id }));
this.invoiceSvc.createListLogPayment(paymentPayload).subscribe(res => {
- // Track bulk payment logging for updated invoice
- paymentPayload.forEach((payment, index) => {
- this.gaSvc.trackInvoicePaymentLogged({
- invoice_id: invoice._id,
- payment_amount: parseFloat(payment.amount) || 0,
- payment_method: this.gaHelpers.mapPaymentMethod(payment.paymentMethod),
- payment_date: payment.paymentDate?.toISOString?.()?.split('T')[0] || new Date().toISOString().split('T')[0],
- remaining_balance: 0, // Full payment scenario
- days_to_payment: this.gaHelpers.calculateDaysToPayment(this.invoice?.openDate, payment.paymentDate),
- payment_reference: `${this.gaHelpers.generatePaymentReference(this.invoice?.code)}-${index + 1}`,
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
- });
-
this.store.dispatch(new invoiceAction.UpdateSuccess(invoice));
}, err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.create).replace('#thing#', $localize`:@@logPayment:Log Payment`));
@@ -1092,70 +1002,6 @@ export class InvoiceEditComponent extends BaseComp implements OnInit, OnDestroy
});
}
- // GA4 Analytics Helper Methods
- private calculateDueDateDays(dueDate: Date): number {
- if (!dueDate) return 30; // Default to 30 days
- const today = new Date();
- const due = new Date(dueDate);
- const timeDiff = due.getTime() - today.getTime();
- return Math.ceil(timeDiff / (1000 * 3600 * 24));
- }
-
- private getModifiedFields(): string[] {
- const fields = [];
- if (!this._orgInvoice) return fields;
-
- // Compare key fields
- if (this.invoice.status !== this._orgInvoice.status) fields.push('status');
- if (this.invoice.dueDate !== this._orgInvoice.dueDate) fields.push('due_date');
- if (this.invoice.paymentTerm !== this._orgInvoice.paymentTerm) fields.push('payment_terms');
- if (this.selectedJobs?.length !== this._orgSelectedJobs?.length) fields.push('jobs');
- if (this.selectedClients?.length !== this._orgSelectedClients?.length) fields.push('clients');
-
- return fields;
- }
-
- private calculateAmountChange(): number {
- if (!this._orgInvoice) return 0;
- const currentAmount = this.totalTotal || 0;
- const originalAmount = this.calculateInvoiceAmount(this._orgInvoice);
- return currentAmount - originalAmount;
- }
-
- private determineModificationType(): 'amount' | 'due_date' | 'jobs' | 'customer' | 'payment_terms' {
- const modifiedFields = this.getModifiedFields();
- if (modifiedFields.includes('jobs')) return 'jobs';
- if (modifiedFields.includes('clients')) return 'customer';
- if (modifiedFields.includes('payment_terms')) return 'payment_terms';
- if (modifiedFields.includes('due_date')) return 'due_date';
- return 'amount';
- }
-
- private getUserRole(): 'admin' | 'applicator' | 'office_admin' | 'client' | 'officer' | 'pilot' | 'inspector' | 'aircraft' {
- const roles = this.authSvc.user?.roles || [];
- if (roles.includes(RoleIds.ADMIN)) return 'admin';
- if (roles.includes(RoleIds.APP)) return 'applicator';
- if (roles.includes(RoleIds.APP_ADM)) return 'office_admin';
- if (roles.includes(RoleIds.PILOT)) return 'pilot';
- if (roles.includes(RoleIds.OFFICER)) return 'officer';
- if (roles.includes(RoleIds.INSPECTOR)) return 'inspector';
- if (roles.includes(RoleIds.DEVICE)) return 'aircraft';
- return 'client';
- }
-
- private calculateInvoiceAmount(invoice: any): number {
- if (!invoice) return 0;
- return invoice.totalAmount || 0;
- }
-
- private calculateDaysInStatus(): number {
- if (!this.invoice?.openDate) return 0;
- const statusDate = new Date(this.invoice.openDate);
- const now = new Date();
- const timeDiff = now.getTime() - statusDate.getTime();
- return Math.ceil(timeDiff / (1000 * 3600 * 24));
- }
-
ngOnDestroy() {
super.ngOnDestroy();
}
diff --git a/Development/client/src/app/invoices/invoices-list/invoices-list.component.html b/Development/client/src/app/invoices/invoices-list/invoices-list.component.html
index 6204209..8e1fb28 100644
--- a/Development/client/src/app/invoices/invoices-list/invoices-list.component.html
+++ b/Development/client/src/app/invoices/invoices-list/invoices-list.component.html
@@ -17,11 +17,11 @@
-
+
-
+
@@ -30,7 +30,7 @@
{{cols[0].header}} {{invoice.totalAmount | currency: invoice.currency : 'symbol-narrow' : '1.0-2'}}
{{cols[1].header}} {{invoice.code}}
- {{cols[2].header}} {{invoice.clientsDisplay}}
+ {{cols[2].header}} {{invoice.clients}}
{{cols[3].header}} {{invoice.openDate | date:'shortDate'}}
{{cols[4].header}} {{invoice.dueDate | date:'shortDate'}}
{{cols[5].header}} {{ (invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)) | invoiceStatus }}
diff --git a/Development/client/src/app/invoices/invoices-list/invoices-list.component.ts b/Development/client/src/app/invoices/invoices-list/invoices-list.component.ts
index def6f53..f157504 100644
--- a/Development/client/src/app/invoices/invoices-list/invoices-list.component.ts
+++ b/Development/client/src/app/invoices/invoices-list/invoices-list.component.ts
@@ -15,7 +15,6 @@ import { InvoiceService } from '@app/domain/services/invoice.service';
import { FilterUtils } from 'primeng/utils';
import { DateUtils, Utils } from '@app/shared/utils';
import { RestoreTableState } from '@app/shared/restore-table-state';
-import { GAService } from '@app/shared/ga.service';
@Component({
selector: 'agm-invoices-list',
@@ -58,7 +57,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
this.cols = [
{ field: 'totalAmount', header: $localize`:@@totalAmount:Total Amount`, filtered: false, filterMatchMode: 'contains' },
{ field: 'code', header: $localize`:@@invoiceNumber:Invoice Number`, filtered: true, filterMatchMode: 'contains' },
- { field: 'clientsDisplay', header: globals.clients, width: '20%', filtered: true, filterMatchMode: 'contains' },
+ { field: 'clients', header: globals.clients, width: '20%', filtered: true, filterMatchMode: 'contains' },
{ field: 'openDate', header: $localize`:@@openDate:Open Date`, filtered: false, filterMatchMode: 'contains' },
{ field: 'dueDate', header: $localize`:@@dueDate:Due Date`, filtered: false, filterMatchMode: 'contains' },
{ field: 'status', header: $localize`:@@status:Status` },
@@ -82,19 +81,8 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
this.invoices = invoices
.map(i => ({
...i,
- totalAmount: this.calculateInvoiceAmount(i),
- clientsDisplay: i?.clients?.map(client => client.billTo?.name)?.join(' ; ') || ''
+ ...this.invoiceRowDataFormatter(i)
}));
-
- // Track invoice list viewed
- this.gaSvc.trackInvoiceListViewed({
- view_type: 'table',
- total_invoices: this.invoices.length,
- displayed_invoices: Math.min(this.invoices.length, 10), // Default page size
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
}
});
this.store.dispatch(new invoiceActions.Fetch());
@@ -148,14 +136,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
&& field
&& filterName;
- if (canFilter) {
- this.dt.filter(range, field, filterName);
-
- // Track date range filtering
- setTimeout(() => {
- this.trackFilterOperation('date_range', range, this.dt.filteredValue?.length || this.invoices.length);
- }, 100);
- }
+ if (canFilter) return this.dt.filter(range, field, filterName);
}
closeCal(range, field, filterName) {
@@ -167,12 +148,7 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
if (canFilter) {
range[1] = range[0];
- this.dt.filter(range, field, filterName);
-
- // Track date range filtering
- setTimeout(() => {
- this.trackFilterOperation('date_range', range, this.dt.filteredValue?.length || this.invoices.length);
- }, 100);
+ return this.dt.filter(range, field, filterName);
}
}
@@ -195,6 +171,17 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
return Utils.arraySum(invoice?.clients.map(client => this.calculateSingleClientInvoiceAmount(client).total));
}
+ invoiceRowDataFormatter(invoice) {
+ const row = {
+ totalAmount: this.calculateInvoiceAmount(invoice),
+ poNumber: invoice?.poNumber,
+ clients: invoice?.clients?.map(i => i.billTo?.name)?.join(' ; '),
+ openDate: invoice?.openDate,
+ dueDate: invoice?.dueDate,
+ };
+ return row;
+ }
+
get canEdit(): boolean {
return this.authSvc.hasRole([RoleIds.APP]);
}
@@ -208,35 +195,11 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
editInvoice(invoice: Invoice) {
this.selectInvoice(invoice);
-
- // Track invoice selection
- this.gaSvc.trackInvoiceSelected({
- invoice_id: invoice._id,
- selection_method: 'edit_button',
- invoice_status: invoice.status,
- invoice_amount: this.calculateInvoiceAmount(invoice),
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
-
this.router.navigate([`/invoices/edit/${invoice._id}`]);
}
viewInvoice(invoice: Invoice) {
this.selectInvoice(invoice);
-
- // Track invoice selection
- this.gaSvc.trackInvoiceSelected({
- invoice_id: invoice._id,
- selection_method: 'view_button',
- invoice_status: invoice.status,
- invoice_amount: this.calculateInvoiceAmount(invoice),
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
-
this.router.navigate([`./detail/${invoice._id}`], { relativeTo: this.route });
}
@@ -257,18 +220,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
const payload = {
invoiceIds: this.selectedInvoice.map(i => i._id)
};
-
- // Track invoice bulk action
- this.gaSvc.trackInvoiceBulkAction({
- action_type: 'delete',
- invoice_count: this.selectedInvoice.length,
- invoice_ids: this.selectedInvoice.map(i => i._id),
- total_amount_affected: this.selectedInvoice.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0),
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
-
this.store.dispatch(new invoiceActions.Delete(payload));
this.selectedInvoice = [];
}
@@ -296,18 +247,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
} else {
saveAs(res, `Agmission_invoices_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.csv`);
}
-
- // Track invoice bulk export
- this.gaSvc.trackInvoiceBulkAction({
- action_type: 'export',
- invoice_count: payload.length,
- invoice_ids: payload.map(i => i._id),
- total_amount_affected: payload.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0),
- success_rate: 1.0,
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
} catch (error) {
alert('Sorry. Your browser does not support this feature !');
}
@@ -326,18 +265,6 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
} else {
saveAs(res, `Agmission_invoices_${this.datePipe.transform(new Date(), 'yyyy-MM-ddTHH-mm-ss')}.iif`);
}
-
- // Track invoice bulk export
- this.gaSvc.trackInvoiceBulkAction({
- action_type: 'export',
- invoice_count: payload.length,
- invoice_ids: payload.map(i => i._id),
- total_amount_affected: payload.reduce((sum, inv) => sum + this.calculateInvoiceAmount(inv), 0),
- success_rate: 1.0,
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
} catch (error) {
alert('Sorry. Your browser does not support this feature !');
}
@@ -350,52 +277,4 @@ export class InvoicesListComponent extends BaseComp implements OnInit, OnDestroy
ngOnDestroy(): void {
super.ngOnDestroy();
}
-
- private trackFilterOperation(filterType: 'status' | 'date_range' | 'client' | 'amount_range' | 'overdue', filterValue: any, resultsAfter: number) {
- // Track invoice list filtering
- this.gaSvc.trackInvoiceListFiltered({
- filter_type: filterType,
- filter_value: filterValue,
- results_before: this.invoices.length,
- results_after: resultsAfter,
- filter_effectiveness: this.invoices.length > 0 ? resultsAfter / this.invoices.length : 0,
- multiple_filters_active: this.hasMultipleFiltersActive(),
- user_id: this.getAnalyticsUserId(),
- user_role: this.getAnalyticsUserRole(),
- platform: 'web'
- });
- }
-
- private hasMultipleFiltersActive(): boolean {
- if (!this.dt?.filters) return false;
-
- const activeFilters = Object.keys(this.dt.filters)
- .filter(key => this.dt.filters[key]?.value !== null && this.dt.filters[key]?.value !== undefined && this.dt.filters[key]?.value !== '');
-
- return activeFilters.length > 1;
- }
-
- onTextFilter(event: any, field: string, matchMode: string) {
- const filterValue = event.target.value;
- this.dt.filter(filterValue, field, matchMode);
-
- // Track filtering after a short delay to ensure the table is updated
- setTimeout(() => {
- if (filterValue && filterValue.trim()) {
- const filterType = field === 'clientsDisplay' ? 'client' : field === 'totalAmount' ? 'amount_range' : 'client';
- this.trackFilterOperation(filterType, filterValue, this.dt.filteredValue?.length || this.invoices.length);
- }
- }, 100);
- }
-
- onStatusFilter(event: any) {
- this.dt.filter(event.value, 'status', 'in');
-
- // Track filtering after a short delay to ensure the table is updated
- setTimeout(() => {
- if (event.value && event.value.length > 0) {
- this.trackFilterOperation('status', event.value, this.dt.filteredValue?.length || this.invoices.length);
- }
- }, 100);
- }
-}
\ No newline at end of file
+}
diff --git a/Development/client/src/app/job/effects/job.effects.ts b/Development/client/src/app/job/effects/job.effects.ts
index 68eee85..1a248a9 100644
--- a/Development/client/src/app/job/effects/job.effects.ts
+++ b/Development/client/src/app/job/effects/job.effects.ts
@@ -51,20 +51,7 @@ export class JobEffects {
ofType(jobActions.CREATE),
switchMap(({ payload }) =>
this.jobSvc.createJob(payload).pipe(
- map((job) => {
- // Track job creation with GA4
- this.gaSvc.trackJobCreated({
- user_id: 'system',
- platform: 'web',
- job_type: this.normalizeJobType(payload.appType),
- field_size_acres: payload.ttSprArea || 0,
- crop_type: payload.crop?.name || 'unknown',
- client_id: payload.client?._id?.toString() || 'unknown',
- priority: 'medium' // Default priority
- });
-
- return new jobActions.CreateSuccess(job);
- }),
+ map((job) => new jobActions.CreateSuccess(job)),
catchError(err => {
this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.job));
return of(new jobActions.CreateFailed())
@@ -76,37 +63,11 @@ export class JobEffects {
@Effect()
updateJob$: Observable = this.actions$.pipe(
ofType(jobActions.UPDATE),
- switchMap(({ payload }) => {
- const oldStatus = payload.job?.status;
-
- return this.jobSvc.saveJob(payload).pipe(
+ switchMap(({ payload }) =>
+ this.jobSvc.saveJob(payload).pipe(
map((data) => {
- const updatedJob = toJob(data);
- const newStatus = updatedJob.status;
-
- // Check if status changed during update
- if (oldStatus !== undefined && oldStatus !== newStatus) {
- this.gaSvc.trackJobStatusChanged({
- user_id: 'system',
- platform: 'web',
- job_id: payload.job?._id?.toString() || 'unknown',
- old_status: this.mapStatusToString(oldStatus),
- new_status: this.mapStatusToString(newStatus),
- status_change_reason: 'api_update'
- });
- }
-
- // Track general job update
- this.gaSvc.trackJobUpdated({
- user_id: 'system',
- platform: 'web',
- job_id: payload.job?._id?.toString() || 'unknown',
- fields_modified: this.detectModifiedFields(payload, updatedJob),
- change_magnitude: oldStatus !== newStatus ? 'major' : 'minor',
- save_method: 'manual'
- });
-
- return new jobActions.UpdateSuccess(updatedJob)
+ this.gaSvc.gaEvent("JOBS", "CRUD", "U");
+ return new jobActions.UpdateSuccess(toJob(data))
}),
catchError(err => {
if (err?.error?.error['.tag'] == 'cannot_edit_job_have_invoice_opened') {
@@ -117,7 +78,7 @@ export class JobEffects {
return of(new jobActions.UpdateFailed());
})
)
- })
+ )
);
@Effect()
@@ -126,19 +87,7 @@ export class JobEffects {
switchMap(({ payload }) =>
this.jobSvc.deleteJob(payload).pipe(
map(() => {
- // Track job deletion with GA4
- this.gaSvc.trackJobDeleted({
- user_id: 'system', // Effects don't have direct user context
- platform: 'web',
- job_id: payload._id?.toString() || 'unknown',
- job_type: payload.appType || 'unknown',
- job_status: payload.status?.toString() || 'unknown',
- deletion_reason: 'user_action',
- deletion_method: 'api_call',
- time_since_creation: payload.createdAt ?
- Math.floor((new Date().getTime() - new Date(payload.createdAt).getTime()) / (1000 * 60 * 60)) : 0
- });
-
+ this.gaSvc.gaEvent("JOBS", "CRUD", "D");
return new jobActions.DeleteSuccess(payload)
}),
catchError(err => {
@@ -156,17 +105,7 @@ export class JobEffects {
this.jobSvc.assign(payload).pipe(
map(() => {
this.msgSvc.addSuccessMsg($localize`:@@jobAssigned:Job assigned`);
-
- // Track job assignment with GA4
- this.gaSvc.trackJobAssigned({
- user_id: 'system',
- platform: 'web',
- job_id: payload.jobId?.toString() || 'unknown',
- assignee_id: payload.asUsers?.[0]?._id?.toString() || 'unknown',
- assignee_role: 'applicator', // Updated to use valid role
- assignment_method: 'manual'
- });
-
+ this.gaSvc.gaEvent("JOBS", "DATA", "A");
return new jobActions.AssignSuccess({ _id: payload.jobId })
}),
catchError(err => {
@@ -193,49 +132,4 @@ export class JobEffects {
)
)
);
-
- // Helper method to normalize job type to GA4 enum values
- private normalizeJobType(appType: string): 'spraying' | 'seeding' | 'fertilizing' | 'harvesting' {
- const type = appType?.toLowerCase();
- if (type?.includes('spray')) return 'spraying';
- if (type?.includes('seed')) return 'seeding';
- if (type?.includes('fertiliz')) return 'fertilizing';
- if (type?.includes('harvest')) return 'harvesting';
- return 'spraying'; // Default fallback
- }
-
- /**
- * Map numeric status to string for GA4 tracking
- */
- private mapStatusToString(status: number): 'new' | 'ready' | 'downloaded' | 'sprayed' | 'archived' {
- switch (status) {
- case 0: return 'new';
- case 1: return 'ready';
- case 2: return 'downloaded';
- case 3: return 'sprayed';
- case 9: return 'archived';
- default: return 'new';
- }
- }
-
- /**
- * Detect which fields were modified in the job update
- */
- private detectModifiedFields(payload: any, updatedJob: any): string[] {
- const modifiedFields: string[] = [];
- const originalJob = payload.job;
-
- if (!originalJob) return ['unknown'];
-
- // Check common fields that might change
- if (originalJob.status !== updatedJob.status) modifiedFields.push('status');
- if (originalJob.name !== updatedJob.name) modifiedFields.push('name');
- if (originalJob.priority !== updatedJob.priority) modifiedFields.push('priority');
- if (originalJob.startDate !== updatedJob.startDate) modifiedFields.push('startDate');
- if (originalJob.endDate !== updatedJob.endDate) modifiedFields.push('endDate');
- if (originalJob.operator?._id !== updatedJob.operator?._id) modifiedFields.push('operator');
- if (originalJob.vehicle?._id !== updatedJob.vehicle?._id) modifiedFields.push('vehicle');
-
- return modifiedFields.length > 0 ? modifiedFields : ['unknown'];
- }
}
diff --git a/Development/client/src/app/job/job-assignment/job-assignment.component.css b/Development/client/src/app/job/job-assignment/job-assignment.component.css
deleted file mode 100644
index df67034..0000000
--- a/Development/client/src/app/job/job-assignment/job-assignment.component.css
+++ /dev/null
@@ -1,393 +0,0 @@
-/* Job Assignment Component Styles - AgMission Theme Compliance */
-
-/* Host element typography foundation - AgMission standards */
-/* These properties cascade to all child elements, reducing repetition */
-: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 */
-}
-
-.job-assignment-container {
- margin-top: 20px;
-}
-
-/* Aircraft Item Styling */
-.aircraft-item {
- display: flex;
- align-items: center;
- border-bottom: 1px solid #bdbdbd;
- /* $dividerColor */
- position: relative;
-}
-
-.aircraft-icon {
- color: #03A9F4;
- /* $blue - info states */
- font-size: 16px;
-}
-
-.aircraft-name {
- flex: 1;
- font-weight: 500;
- cursor: pointer;
-}
-
-/* Aircraft Tooltip Styling */
-:host ::ng-deep .aircraft-tooltip-enhanced {
- max-width: 350px;
- white-space: pre-line;
- line-height: 1.4;
- font-size: 13px;
- background: #2E7D32;
- /* $primaryDarkColor */
- color: #ffffff;
- /* $primaryTextColor */
- border: 1px solid #4CAF50;
- /* $primaryColor */
- border-radius: 3px;
- /* AgMission standard border radius */
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- padding: 12px 14px;
-}
-
-:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-text {
- background: transparent;
- color: inherit;
- border: none;
- padding: 0;
-}
-
-:host ::ng-deep .aircraft-tooltip-enhanced .ui-tooltip-arrow::before {
- border-top-color: #2E7D32;
- /* $primaryDarkColor */
-}
-
-.aircraft-details {
- margin-top: 4px;
- font-size: 0.85rem;
- color: #757575;
- /* $textSecondaryColor */
-}
-
-.sync-status {
- margin-left: 8px;
-}
-
-/* Download Options Info */
-.download-options-info {
- display: flex;
- align-items: center;
- margin-top: 4px;
- font-size: 0.85rem;
- color: #757575;
- /* $textSecondaryColor */
-}
-
-.download-options-info .pi {
- margin-right: 4px;
- color: #4CAF50;
- /* $primaryColor - success indicator */
-}
-
-/* Assignment Status Styling */
-.assignment-status-section {
- background: #ffffff;
- /* $contentBgColor */
- border-radius: 3px;
- /* AgMission standard border radius */
- padding: 16px;
- border: 1px solid #bdbdbd;
- /* $dividerColor */
-}
-
-.assignment-status-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16px;
-}
-
-.assignment-status-header h4 {
- font-size: 1.25rem;
- font-weight: 600;
- color: #212121;
- /* $textColor - matches other page labels */
- margin: 0;
-}
-
-.assignment-header-actions {
- display: flex;
- gap: 8px;
- align-items: center;
-}
-
-.status-control-btn,
-.clear-status-btn {
- padding: 8px 12px !important;
- font-size: 14px !important;
- min-width: 44px !important;
- min-height: 44px !important;
- border-radius: 3px !important;
- /* AgMission standard border radius */
-}
-
-.status-control-btn:focus,
-.clear-status-btn:focus {
- outline: 2px solid #03A9F4;
- /* $blue - info states */
- outline-offset: 2px;
-}
-
-.polling-status-indicator {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 16px;
- background-color: #E1F5FE;
- /* Light blue background for info */
- border: 1px solid #03A9F4;
- /* $blue */
- border-radius: 3px;
- /* AgMission standard border radius */
- margin-bottom: 12px;
- font-size: 0.95rem;
- color: #0277BD;
- /* $blueHover */
-}
-
-.assignment-progress {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 16px;
- background-color: #FFF8E1;
- /* Light amber background for progress */
- border: 1px solid #FFC107;
- /* $amber */
- border-radius: 3px;
- /* AgMission standard border radius */
- margin-bottom: 12px;
- font-weight: 600;
- font-size: 0.95rem;
- color: #FF8F00;
- /* $amberHover */
-}
-
-.assignment-error-summary {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 12px 16px;
- background-color: #FFEBEE;
- /* Light red background for error */
- border: 1px solid #F44336;
- /* $red */
- border-radius: 3px;
- /* AgMission standard border radius */
- margin-bottom: 12px;
- color: #C62828;
- /* $redHover */
- font-weight: 600;
- font-size: 0.95rem;
-}
-
-/* Assignment Status Table Styling */
-.assignment-status-table {
- margin-top: 12px;
- border-radius: 3px;
- /* AgMission standard border radius */
- overflow: hidden;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-.assignment-status-table .ui-table-thead th {
- background-color: #e8e8e8;
- /* $hoverBgColor */
- border-bottom: 2px solid #bdbdbd;
- /* $dividerColor */
- color: #212121;
- /* $textColor */
- font-weight: 600;
- font-size: 0.9rem;
- padding: 12px 8px;
-}
-
-.assignment-status-table .ui-table-tbody>tr {
- border-left: 4px solid transparent;
- transition: background-color 0.2s ease;
-}
-
-.assignment-status-table .ui-table-tbody>tr:hover {
- background-color: #e8e8e8;
- /* $hoverBgColor */
-}
-
-.assignment-status-table .ui-table-tbody>tr>td {
- padding: 12px 8px;
- font-size: 0.9rem;
- border-bottom: 1px solid #bdbdbd;
- /* $dividerColor */
-}
-
-.assignment-status-table .status-row-new {
- border-left-color: #4527A0;
- /* $accentDarkColor - new assignments */
-}
-
-.assignment-status-table .status-row-downloaded {
- border-left-color: #f9a825;
- /* $accentLightColor - downloaded assignments */
-}
-
-.assignment-status-table .status-row-uploaded {
- border-left-color: #2E7D32;
- /* $primaryDarkColor - uploaded/completed assignments */
-}
-
-.assignment-status-table .status-row-error {
- border-left-color: #F44336;
- /* Semantic red - error states */
-}
-
-.aircraft-cell {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.aircraft-cell .pi {
- font-size: 16px;
- color: #03A9F4;
- /* $blue - info states */
-}
-
-.aircraft-cell .aircraft-name {
- font-weight: 600;
- color: #212121;
- /* $textColor */
-}
-
-/* Status message and error details - AgMission Typography */
-.status-message {
- font-weight: 500;
- margin-top: 6px;
- color: #212121;
- /* $textColor */
- font-size: 0.9rem;
-}
-
-.status-error-details {
- margin-top: 6px;
- color: #757575;
- /* $textSecondaryColor */
- font-size: 0.85rem;
- font-style: italic;
-}
-
-.status-timestamp {
- font-size: 0.9rem;
- color: #757575;
- /* $textSecondaryColor */
- font-weight: 500;
-}
-
-.status-actions {
- display: flex;
- justify-content: center;
- align-items: center;
-}
-
-.status-indicator-text {
- display: flex;
- align-items: center;
- gap: 6px;
- color: #757575;
- /* $textSecondaryColor */
- font-size: 0.9rem;
- font-weight: 500;
-}
-
-.assignment-action-button {
- font-size: 12px !important;
- min-height: 32px !important;
-}
-
-.empty-message {
- text-align: center;
- padding: 24px;
- color: #757575;
- /* $textSecondaryColor */
- font-style: italic;
- font-size: 1rem;
-}
-
-/* Screen reader only content */
-.sr-only {
- position: absolute !important;
- width: 1px !important;
- height: 1px !important;
- padding: 0 !important;
- margin: -1px !important;
- overflow: hidden !important;
- clip: rect(0, 0, 0, 0) !important;
- white-space: nowrap !important;
- border: 0 !important;
-}
-
-/* Focus improvements for accessibility */
-.assignment-status-table tbody tr:focus-within {
- outline: 2px solid #03A9F4;
- /* $blue - info states */
- outline-offset: 2px;
-}
-
-/* Responsive design */
-@media (max-width: 768px) {
- .assignment-status-section {
- padding: 12px;
- }
-
- .assignment-status-table .ui-table-thead {
- display: none;
- }
-
- .assignment-status-table .ui-table-tbody>tr>td {
- display: block;
- border: none;
- border-bottom: 1px solid #bdbdbd;
- /* $dividerColor */
- padding: 12px 8px;
- font-size: 0.9rem;
- }
-
- .assignment-status-table .ui-table-tbody>tr>td:before {
- content: attr(data-label) ": ";
- font-weight: 600;
- display: inline-block;
- width: 120px;
- color: #212121;
- /* $textColor */
- }
-
- .assignment-header-actions {
- flex-wrap: wrap;
- gap: 6px;
- }
-
- .status-control-btn,
- .clear-status-btn {
- padding: 6px 10px !important;
- font-size: 12px !important;
- min-width: 40px !important;
- min-height: 40px !important;
- }
-
- .polling-status-indicator {
- padding: 8px 12px;
- font-size: 0.85rem;
- }
-}
\ No newline at end of file
diff --git a/Development/client/src/app/job/job-assignment/job-assignment.component.html b/Development/client/src/app/job/job-assignment/job-assignment.component.html
deleted file mode 100644
index 8ea30d7..0000000
--- a/Development/client/src/app/job/job-assignment/job-assignment.component.html
+++ /dev/null
@@ -1,205 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ aircraft.name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Download Options :
-
-
- {{ Labels.AGNAV_BRAND_NAME }} Aircraft
- Only
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Polling assignment status...
- (Updates every 5 seconds)
-
-
-
-
-
- Assignment in progress...
-
-
-
-
-
- {{ assignmentErrorMsg }}
-
-
-
-
0" [value]="assignmentStatuses" dataKey="aircraftId"
- [responsive]="true" [scrollable]="assignmentStatuses.length > 4"
- [scrollHeight]="assignmentStatuses.length > 4 ? '300px' : 'auto'" styleClass="assignment-status-table"
- [pTooltip]="getStatusTableTooltip()" tooltipPosition="top">
-
-
-
- Aircraft
-
-
- Status & Message
- Assign Time
- Actions
-
-
-
-
-
-
-
- Aircraft
-
-
-
{{ status.aircraftName }}
-
-
-
-
-
-
-
-
- Status & Message
-
-
-
{{ status.message }}
-
- {{ status.errorDetails }}
-
-
-
-
-
-
- Assign Time
- {{ status.timestamp | date:'short' }}
-
-
-
-
- Actions
-
-
-
-
-
-
-
-
- {{ Labels.PROCESSING_ASSIGNMENT }}
-
-
-
-
-
-
-
-
-
-
- No assignment status to display
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Development/client/src/app/job/job-assignment/job-assignment.component.ts b/Development/client/src/app/job/job-assignment/job-assignment.component.ts
deleted file mode 100644
index edb4852..0000000
--- a/Development/client/src/app/job/job-assignment/job-assignment.component.ts
+++ /dev/null
@@ -1,822 +0,0 @@
-import { Component, OnInit, OnDestroy, Input, Output, EventEmitter, ChangeDetectorRef } from '@angular/core';
-import { Observable, Subject, timer, interval } from 'rxjs';
-import { switchMap, takeUntil, retryWhen, delayWhen, startWith } from 'rxjs/operators';
-import { Store } from '@ngrx/store';
-
-import { MenuItem, SelectItem } from 'primeng/api';
-
-import { IUIJob } from '../models/job.model';
-import * as fromEntity from '@app/entities/reducers';
-import * as jobActions from '../actions/job.actions';
-
-import { JobService } from '@app/domain/services/job.service';
-import { PartnerService } from '@app/partners/services/partner.service';
-import { PartnerUtilsService } from '@app/shared/services/partner-utils.service';
-import { BadgeFactoryService } from '@app/shared/services/badge-factory.service';
-import { AuthService } from '@app/domain/services/auth.service';
-
-import { AircraftAssignmentItem } from '@app/entities/models/vehicle.model';
-import { Partner } from '@app/partners/models/partner.model';
-import { BadgeConfig } from '@app/shared/badge/badge-config.model';
-
-import { BaseComp } from '@app/shared/base/base.component';
-import { SourceSystem, OperationalStatus, AssignStatus, AssignStatusType, Labels, globals, KnownPartnerCodes, SystemOrPartnerType } from '@app/shared/global';
-
-// ============================================================================
-// INTERFACES
-// ============================================================================
-
-// Assignment Status Tracking Interface
-interface AssignmentStatus {
- aircraftId: string;
- aircraftName: string;
- sourceSystem: SystemOrPartnerType; // Track source system for badge display
- state: AssignStatusType; // Using AssignStatus values
- message: string;
- timestamp: Date;
- errorDetails?: string;
-}
-
-/**
- * Job Assignment Component
- *
- * Supports both AgNav and partner aircraft assignment to jobs.
- *
- * Partner Info Response Structure (from assignments_post):
- * - AgNav vehicles: No partnerInfo field
- * - Partner vehicles: { partnerInfo: { name: "satloc", partnerCode: "SATLOC" } }
- */
-@Component({
- selector: 'agm-job-assignment',
- templateUrl: './job-assignment.component.html',
- styleUrls: ['./job-assignment.component.css']
-})
-export class JobAssignmentComponent extends BaseComp implements OnInit, OnDestroy {
- // Template readonly objects for direct usage
- readonly SourceSystem = SourceSystem;
- readonly KnownPartnerCodes = KnownPartnerCodes;
- readonly OperationalStatus = OperationalStatus;
- readonly Labels = Labels;
-
- // Inputs from parent component
- @Input() job: IUIJob;
- @Input() isArchived: boolean = false;
- @Input() canDownload: boolean = false;
- @Input() dlOps: SelectItem[] = [];
-
- // Outputs to parent component
- @Output() assignmentComplete = new EventEmitter();
-
- // Assignment-related properties
- srcUsers: AircraftAssignmentItem[] = [];
- tarUsers: AircraftAssignmentItem[] = [];
- allAircraft: AircraftAssignmentItem[] = []; // Store all aircraft data
-
- // Assignment Status Tracking
- assignmentStatuses: AssignmentStatus[] = [];
- isAssignmentInProgress = false;
- assignmentErrorMsg: string | null = null;
-
- // Assignment Status Polling
- isPollingAssignments = false;
- private stoppedAssignmentPoll: Subject;
-
- // Partner caching for performance
- private partnersCache = new Map();
-
- constructor(
- protected store: Store,
- private jobSvc: JobService,
- private partnerSvc: PartnerService,
- private partnerUtils: PartnerUtilsService,
- private badgeFactory: BadgeFactoryService,
- protected authSvc: AuthService,
- private cdr: ChangeDetectorRef
- ) {
- super(cdr);
- }
-
- ngOnInit(): void {
- this.stoppedAssignmentPoll = new Subject();
-
- // Load partners for aircraft display
- this.loadPartners();
-
- // Load aircraft data
- this.loadAircraftData();
- }
-
- ngOnDestroy(): void {
- // Stop assignment status polling
- this.stopAssignmentStatusPolling();
- super.ngOnDestroy();
- }
-
- /**
- * Load partners and cache them for performance
- */
- private loadPartners(): void {
- this.partnerSvc.getPartners().subscribe({
- next: (partners) => {
- this.partnersCache.clear();
- partners.forEach(partner => {
- this.partnersCache.set(partner._id, partner);
- });
-
- // Refresh aircraft data now that partners are loaded
- this.refreshAircraftDisplay();
- },
- error: (error) => {
- console.error(globals.consoleFailedToLoadPartners, error);
- }
- });
- }
-
- /**
- * Get partner from cache by ID
- */
- private getPartner(partnerId: string): Partner | null {
- return this.partnersCache.get(partnerId) || null;
- }
-
- /**
- * Load aircraft data from backend API for job assignment
- */
- private loadAircraftData(): void {
- if (!this.job || !this.job._id) {
- return;
- }
-
- // Use backend API to get assignment data with partnerInfo
- this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
- next: (assignmentData) => {
- this.updateAircraftAssignmentData(assignmentData);
- },
- error: (error) => {
- console.error(globals.consoleFailedToLoadExistingAssignments, error);
- }
- });
- }
-
- /**
- * Update aircraft assignment data from backend API response
- */
- private updateAircraftAssignmentData(assignmentData: any): void {
- // Process available users
- let availableAircraft: AircraftAssignmentItem[] = [];
- if (assignmentData.avUsers && assignmentData.avUsers.length > 0) {
- availableAircraft = assignmentData.avUsers.map(user => this.convertBackendUserToAssignmentItem(user));
- }
-
- // Process assigned users
- let assignedAircraft: AircraftAssignmentItem[] = [];
- if (assignmentData.asUsers && assignmentData.asUsers.length > 0) {
- assignedAircraft = assignmentData.asUsers.map(user => this.convertBackendUserToAssignmentItem(user));
- }
-
- // Combine all aircraft for sorting
- this.allAircraft = this.sortAircraftBySource([...availableAircraft, ...assignedAircraft]);
-
- // Set source users (available) and target users (assigned)
- this.srcUsers = availableAircraft;
- this.tarUsers = assignedAircraft;
-
- // Always update assignment status to reflect current state (including when empty)
- this.updateAssignmentStatus(assignmentData);
-
- // Start polling if there are assigned aircraft to track
- if (assignedAircraft.length > 0 && !this.isPollingAssignments) {
- this.startAssignmentStatusPolling();
- } else if (assignedAircraft.length === 0 && this.isPollingAssignments) {
- // Stop polling if no assignments
- this.stopAssignmentStatusPolling();
- }
- }
-
- /**
- * Refresh aircraft display after partners are loaded
- */
- private refreshAircraftDisplay(): void {
- // Reload aircraft data from backend API now that partners are cached
- // This ensures partner names are resolved correctly
- if (!this.job || !this.job._id) {
- return;
- }
-
- this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
- next: (assignmentData) => {
- this.updateAircraftAssignmentData(assignmentData);
- // Trigger change detection to update the UI with partner names
- this.cdr.detectChanges();
- },
- error: (error) => {
- console.error(globals.consoleFailedToLoadExistingAssignments, error);
- }
- });
- }
-
- /**
- * Convert backend user object (from assignments API) to AircraftAssignmentItem
- */
- private convertBackendUserToAssignmentItem(user: any): AircraftAssignmentItem {
- // Determine partner information from partnerInfo
- let partnerId: string | undefined;
- let partnerName: string | undefined;
- let partnerCode: string | undefined;
- let sourceSystem: SystemOrPartnerType = SourceSystem.AGNAV; // default
-
- // Check for partnerInfo.partnerCode (from assignments_post response)
- if (user.partnerInfo?.partnerCode) {
- partnerCode = user.partnerInfo.partnerCode;
- partnerName = user.partnerInfo.name;
-
- // Find partner by partnerCode to get partner ID
- const partner = Array.from(this.partnersCache.values()).find(p =>
- p.partnerCode?.toUpperCase() === partnerCode!.toUpperCase()
- );
-
- if (partner) {
- partnerId = partner._id;
- // Use partner ID as sourceSystem for all partner aircraft
- sourceSystem = partnerId as SystemOrPartnerType;
- }
- }
-
- const assignmentItem: AircraftAssignmentItem = {
- _id: user.uid, // Backend returns uid instead of _id
- name: user.name,
- active: user.active || false,
- pkgActive: user.pkgActive || false,
- tailNumber: user.tailNumber,
- username: user.username, // Add username for tooltip display
- partnerSystem: sourceSystem,
- sourceSystem: sourceSystem,
- partnerId: partnerId,
- partnerName: partnerName,
- partnerCode: partnerCode
- };
-
- // Add partner-specific data for all partner aircraft (not just SATLOC)
- if (!this.partnerUtils.isNativeSystem(sourceSystem)) {
- const partnerObj = partnerId ? this.getPartner(partnerId) : null;
-
- // For backward compatibility, keep satlocData for SATLOC aircraft
- if (partnerObj && this.partnerUtils.isSatlocPartner(partnerObj)) {
- assignmentItem.satlocData = {
- tailNumber: user.tailNumber || Labels.N_A,
- syncStatus: user.partnerInfo?.metadata?.syncStatus || OperationalStatus.PENDING
- };
- }
- }
-
- return assignmentItem;
- }
-
- /**
- * Get partner display name for aircraft
- */
- getPartnerDisplayName(aircraft: AircraftAssignmentItem): string {
- if (aircraft.partnerName) {
- return aircraft.partnerName;
- }
- return Labels.AGNAV_BRAND_NAME; // Use consistent non-translatable brand name
- }
-
- /**
- * Get partner display name from source system (for assignment status table)
- */
- getPartnerDisplayNameFromSource(sourceSystem: SystemOrPartnerType): string {
- if (this.partnerUtils.isNativeSystem(sourceSystem)) {
- return Labels.AGNAV_BRAND_NAME;
- }
- const partner = this.getPartner(sourceSystem);
- return partner ? partner.name : sourceSystem.toString();
- }
-
- /**
- * Get simplified tooltip text for aircraft
- * - For AgNav: name username
- * - For Partner: partner name tailNumber
- * - Adds warning if package is not active
- */
- getAircraftTooltip(aircraft: AircraftAssignmentItem): string {
- let tooltip = '';
-
- // For AgNav aircraft: show name and username
- if (aircraft.sourceSystem === SourceSystem.AGNAV) {
- if (aircraft.username) {
- tooltip = `${aircraft.name} ${aircraft.username}`;
- } else {
- tooltip = aircraft.name;
- }
- } else {
- // For Partner aircraft: show partner name and tail number
- const partnerName = this.getPartnerDisplayName(aircraft);
- if (aircraft.tailNumber) {
- tooltip = `${partnerName} ${aircraft.tailNumber}`;
- } else {
- tooltip = partnerName;
- }
- }
-
- // Add package inactive warning if applicable
- if (!aircraft.pkgActive) {
- tooltip += `⚠️ ${Labels.PACKAGE_INACTIVE} `;
- }
-
- return tooltip;
- }
-
- /**
- * Check if aircraft can be assigned to job
- */
- canAssignAircraft(aircraft: AircraftAssignmentItem): boolean {
- // Only check package status - no authentication constraints
- return aircraft.pkgActive === true;
- }
-
- // ============================================================================
- // AIRCRAFT SORTING UTILITIES
- // ============================================================================
-
- /**
- * Sort aircraft by source system (AgNav first, then all partners alphabetically)
- */
- private sortAircraftBySource(aircraft: AircraftAssignmentItem[]): AircraftAssignmentItem[] {
- return aircraft.sort((a, b) => {
- // Primary sort: AgNav first, then all partner systems
- const aIsNative = this.partnerUtils.isNativeSystem(a.sourceSystem);
- const bIsNative = this.partnerUtils.isNativeSystem(b.sourceSystem);
-
- if (aIsNative && !bIsNative) return -1; // AgNav comes first
- if (!aIsNative && bIsNative) return 1; // AgNav comes first
-
- // If both are partners or both are native, sort by partner name then aircraft name
- if (!aIsNative && !bIsNative) {
- // Both are partners - sort by partner name first
- const aPartnerName = a.partnerName || Labels.UNKNOWN_PARTNER;
- const bPartnerName = b.partnerName || Labels.UNKNOWN_PARTNER;
- const partnerCompare = aPartnerName.localeCompare(bPartnerName);
- if (partnerCompare !== 0) return partnerCompare;
- }
-
- // Secondary sort: Alphabetical by aircraft name within each source group
- return a.name.localeCompare(b.name);
- });
- }
-
- /**
- * Main assignment method - handles both AgNav and partner aircraft
- */
- assignJob(): void {
- if (!this.job) {
- return;
- }
-
- // Transform aircraft data to backend API format
- // Backend expects 'uid' property, but frontend model uses '_id'
- const formattedAsUsers = this.tarUsers.map(aircraft => {
- // Base assignment data - ALL aircraft need uid for backend
- const assignmentData: any = {
- uid: aircraft._id, // Required for all aircraft types - backend uses this for 'user' field
- name: aircraft.name
- };
-
- // Add partner-specific data for Satloc aircraft
- if (aircraft.sourceSystem === KnownPartnerCodes.SATLOC && aircraft.satlocData) {
- assignmentData.partnerAircraftId = aircraft.satlocData.satlocId || aircraft._id;
- assignmentData.notes = `${Labels.SATLOC_AIRCRAFT_PREFIX} ${aircraft.satlocData.tailNumber}`;
- assignmentData.jobName = this.job.name;
- }
-
- return assignmentData;
- });
-
- const formattedAvUsers = this.srcUsers.map(aircraft => ({
- uid: aircraft._id,
- name: aircraft.name
- }));
-
- const assignment: jobActions.AssignInfo = {
- jobId: this.job._id,
- dlOp: this.job.dlOp,
- avUsers: formattedAvUsers,
- asUsers: formattedAsUsers
- };
-
- this.store.dispatch(new jobActions.Assign(assignment));
-
- // Start polling immediately if assigning aircraft (to track assignment progress)
- if (formattedAsUsers.length > 0 && !this.isPollingAssignments) {
- this.startAssignmentStatusPolling();
- }
- }
-
- // ============================================================================
- // ASSIGNMENT STATUS POLLING
- // ============================================================================
-
- /**
- * Start polling assignment status from backend API
- */
- private startAssignmentStatusPolling(): void {
- if (this.isPollingAssignments) {
- return; // Already polling
- }
-
- this.isPollingAssignments = true;
- this.stoppedAssignmentPoll.next(false);
-
- const polling$ = this.pollAssignmentStatus().subscribe({
- next: (assignmentData) => {
- this.updateAssignmentStatus(assignmentData);
- },
- error: (error) => {
- console.error(globals.consoleAssignmentStatusPollingError, error);
- this.isPollingAssignments = false;
- // Reset assignment progress flag on polling error to prevent UI from being stuck
- if (this.isAssignmentInProgress) {
- this.isAssignmentInProgress = false;
- }
- }
- });
-
- // Add subscription to component's subscription manager
- this.sub$.add(polling$);
- }
-
- private stopAssignmentStatusPolling(): void {
- if (this.stoppedAssignmentPoll) {
- this.stoppedAssignmentPoll.next(true);
- this.isPollingAssignments = false;
- }
- }
-
- private pollAssignmentStatus(): Observable {
- return interval(10000).pipe( // Poll every 10 seconds for real status updates
- startWith(1000), // Start after 1 second
- switchMap(() => this.jobSvc.getAssignments({ 'jobId': this.job._id })),
- takeUntil(this.stoppedAssignmentPoll),
- retryWhen(errors =>
- errors.pipe(
- delayWhen(val => timer(15 * 1000)) // Retry after 15 seconds on error
- )
- )
- );
- }
-
- private updateAssignmentStatus(assignmentData: any): void {
- // Clear assignment statuses if no assigned users (all unassigned)
- if (!assignmentData || !assignmentData.asUsers || assignmentData.asUsers.length === 0) {
- this.assignmentStatuses = [];
- return;
- }
-
- // Update assignment statuses with real assignment data
- this.assignmentStatuses = assignmentData.asUsers.map((assignedAircraft) => {
- const existingStatus = this.assignmentStatuses.find(s => s.aircraftId === assignedAircraft.uid);
-
- // Use the assignStatus field from the backend data
- const backendStatus = assignedAircraft.assignStatus !== undefined ? assignedAircraft.assignStatus : AssignStatus.NEW;
-
- // Generate status object from real assignment data
- const statusUpdate = this.generateAssignmentStatusFromBackend(assignedAircraft, backendStatus);
-
- // If there's existing status, preserve timestamp if status hasn't changed
- if (existingStatus) {
- return {
- ...statusUpdate,
- // Preserve timestamp if status hasn't changed
- timestamp: existingStatus.state === statusUpdate.state ? existingStatus.timestamp : new Date()
- };
- }
-
- return statusUpdate;
- });
-
- // Check if all assignments have completed (no longer in NEW/pending status)
- this.checkAssignmentProgress();
- }
-
- /**
- * Check assignment progress and update isAssignmentInProgress flag
- */
- private checkAssignmentProgress(): void {
- if (this.assignmentStatuses.length === 0) {
- // No assignments to track
- this.isAssignmentInProgress = false;
- return;
- }
-
- // Check if any assignments are still in NEW (pending) status
- const hasNewAssignments = this.assignmentStatuses.some(status => status.state === AssignStatus.NEW);
-
- if (!hasNewAssignments && this.isAssignmentInProgress) {
- // All assignments have completed (no longer in NEW status)
- this.isAssignmentInProgress = false;
- }
- }
-
- private generateAssignmentStatusFromBackend(assignedAircraft: any, backendStatus: number): AssignmentStatus {
- const statusMessages = {
- [AssignStatus.NEW]: globals.assignmentInProgress,
- [AssignStatus.DOWNLOADED]: globals.assignmentDownloaded,
- [AssignStatus.UPLOADED]: globals.assignmentCompleted,
- [AssignStatus.ERROR]: globals.assignmentFailed
- };
-
- // Find the aircraft in tarUsers or allAircraft to get sourceSystem
- const aircraft = this.tarUsers.find(a => a._id === assignedAircraft.uid) ||
- this.allAircraft.find(a => a._id === assignedAircraft.uid);
- const sourceSystem = aircraft?.sourceSystem || SourceSystem.AGNAV;
-
- return {
- aircraftId: assignedAircraft.uid,
- aircraftName: assignedAircraft.name,
- sourceSystem: sourceSystem,
- state: backendStatus as AssignStatusType,
- message: statusMessages[backendStatus] || globals.unknownStatus,
- timestamp: new Date(),
- // Use actual error details from backend if available
- errorDetails: backendStatus === AssignStatus.ERROR ? assignedAircraft.errorDetails : undefined
- };
- }
-
- // ============================================================================
- // AIRCRAFT SELECTION HANDLERS
- // ============================================================================
-
- /**
- * Handle aircraft selection/click - validate package status only
- */
- async onAircraftSelect(aircraft: AircraftAssignmentItem, event: Event): Promise {
- // Only validate if this is in the source list (available aircraft)
- const isInSourceList = this.srcUsers.some(ac => ac._id === aircraft._id);
- if (!isInSourceList) {
- return;
- }
-
- // Check package status (applies to all aircraft)
- if (!aircraft.pkgActive) {
- // Package not enabled - visual feedback already provided via red highlighting and tooltip
- return;
- }
-
- // No authentication validation - allow all aircraft with active packages to be assigned
- }
-
- /**
- * Aircraft movement event handlers
- */
- async onMoveToTarget(event: any): Promise {
- // Get the aircraft that were just moved
- const movedAircraft = event.items || [];
-
- // Validate each moved aircraft for package status only
- for (const aircraft of movedAircraft) {
- let shouldMoveBack = false;
- let reason = '';
-
- // Check package active status - only constraint remaining
- if (!aircraft.pkgActive) {
- shouldMoveBack = true;
- reason = Labels.PACKAGE_NOT_ENABLED_REASON;
- }
-
- // Move aircraft back to source if validation failed
- if (shouldMoveBack) {
- const aircraftIndex = this.tarUsers.findIndex(ac => ac._id === aircraft._id);
- if (aircraftIndex !== -1) {
- this.tarUsers.splice(aircraftIndex, 1);
- this.srcUsers.push(aircraft);
- }
- }
- }
-
- // Apply sorting to maintain AgNav-first order
- this.tarUsers = this.sortAircraftBySource(this.tarUsers);
- this.srcUsers = this.sortAircraftBySource(this.srcUsers);
- }
-
- onMoveToSource(event: any): void {
- // Apply sorting to maintain AgNav-first order
- this.srcUsers = this.sortAircraftBySource(this.srcUsers);
- this.tarUsers = this.sortAircraftBySource(this.tarUsers);
- }
-
- /**
- * UI Helper Methods (unified badge system)
- * Uses BadgeFactoryService to create configuration-driven badges
- */
-
- /**
- * Get badge configuration for aircraft source system (picklist)
- */
- getAircraftSystemBadge(aircraft: AircraftAssignmentItem): BadgeConfig {
- return this.badgeFactory.createSystemBadge(
- aircraft.sourceSystem,
- this.getPartnerDisplayName(aircraft)
- );
- }
-
- /**
- * Get badge configuration for assignment status source system (status table)
- */
- getStatusSystemBadge(sourceSystem: SystemOrPartnerType): BadgeConfig {
- return this.badgeFactory.createSystemBadge(
- sourceSystem,
- this.getPartnerDisplayNameFromSource(sourceSystem)
- );
- }
-
- /**
- * Get badge configuration for assignment status (status table)
- */
- getAssignmentStatusBadge(status: AssignmentStatus): BadgeConfig {
- return this.badgeFactory.createAssignmentStatusBadge(
- status.state,
- status.message
- );
- }
-
- getRefreshStatusTooltip(): string {
- return Labels.MANUALLY_REFRESH_ASSIGNMENT_STATUS;
- }
-
- getAssignButtonTooltip(): string {
- if (this.isArchived) {
- return Labels.ASSIGN_BUTTON_ARCHIVED_TOOLTIP;
- }
- if (!this.canDownload) {
- return Labels.ASSIGN_BUTTON_NO_BOUNDARY_TOOLTIP;
- }
- return Labels.ASSIGN_BUTTON_READY_TOOLTIP;
- }
-
- getPickListSourceTooltip(): string {
- return Labels.PICK_LIST_SOURCE_TOOLTIP;
- }
-
- getPickListTargetTooltip(): string {
- return Labels.PICK_LIST_TARGET_TOOLTIP;
- }
-
- getDownloadOptionsTooltip(): string {
- return Labels.DOWNLOAD_OPTIONS_DROPDOWN_TOOLTIP;
- }
-
- getStatusTableTooltip(): string {
- return Labels.ASSIGNMENT_STATUS_TABLE_TOOLTIP;
- }
-
- getStatusIconTooltip(status: AssignmentStatus): string {
- switch (status.state) {
- case AssignStatus.NEW:
- return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP;
- case AssignStatus.DOWNLOADED:
- return Labels.ASSIGNMENT_STATUS_DOWNLOADED_TOOLTIP;
- case AssignStatus.UPLOADED:
- return Labels.ASSIGNMENT_STATUS_UPLOADED_TOOLTIP;
- case AssignStatus.ERROR:
- return Labels.ASSIGNMENT_STATUS_ERROR_TOOLTIP;
- default:
- return Labels.ASSIGNMENT_STATUS_NEW_TOOLTIP; // Default to new/pending
- }
- }
-
- /**
- * Assignment Status UI Methods
- */
- refreshAssignmentStatus(): void {
- if (!this.job || !this.job._id) {
- console.warn(globals.consoleCannotRefreshAssignmentStatus);
- return;
- }
-
- this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe({
- next: (assignmentData) => {
- // Update both assignment status AND aircraft lists
- this.updateAircraftAssignmentData(assignmentData);
- },
- error: (error) => {
- console.error(globals.consoleFailedToRefreshAssignmentStatus, error);
- this.msgSvc.addFailedMsg(globals.failedToRefreshAssignmentStatus);
- }
- });
- }
-
- /**
- * Assignment Status Action Methods
- */
- getUnifiedActionOptions(status: AssignmentStatus): MenuItem[] {
- const commonActions = [
- {
- label: globals.clearStatus,
- icon: 'ui-icon-clear',
- command: () => this.clearSingleStatus(status.aircraftId)
- }
- ];
-
- if (status.state === AssignStatus.ERROR) {
- return [
- ...commonActions,
- {
- separator: true
- },
- {
- label: globals.resetToAvailable,
- icon: 'ui-icon-arrow-back',
- command: () => this.resetAircraftToAvailable(status.aircraftId)
- }
- ];
- }
-
- if (status.state === AssignStatus.UPLOADED) {
- return [
- ...commonActions
- ];
- }
-
- // Default actions for any other states
- return commonActions;
- }
-
- clearSingleStatus(aircraftId: string): void {
- this.assignmentStatuses = this.assignmentStatuses.filter(
- status => status.aircraftId !== aircraftId
- );
- }
-
-
-
- resetAircraftToAvailable(aircraftId: string): void {
- // Find the aircraft in assigned list
- const aircraft = this.tarUsers.find(a => a._id === aircraftId);
- if (!aircraft) return;
-
- // Move aircraft back to available list
- this.tarUsers = this.tarUsers.filter(a => a._id !== aircraftId);
-
- // Add to source list only if package active, meets auth requirements, and not already there
- // Partner aircraft: Only require active package
- // Native aircraft: Require active package AND credentials (matches backend filter: username: { $nin: [null, ''] })
- const isPartner = !this.partnerUtils.isNativeSystem(aircraft.sourceSystem);
- const hasCredentials = aircraft.username && aircraft.username !== '';
- const meetsAuthRequirements = isPartner || hasCredentials;
-
- if (aircraft.pkgActive === true && meetsAuthRequirements && !this.srcUsers.find(a => a._id === aircraftId)) {
- this.srcUsers.push(aircraft);
- // Apply sorting to maintain AgNav-first order
- this.srcUsers = this.sortAircraftBySource(this.srcUsers);
- }
-
- // Clear the status
- this.clearSingleStatus(aircraftId);
- }
-
- /**
- * Status helper methods for template
- */
- getStatusIcon(status: AssignmentStatus): string {
- switch (status.state) {
- case AssignStatus.NEW:
- return 'pi-spin pi-spinner';
- case AssignStatus.DOWNLOADED:
- return 'pi-download';
- case AssignStatus.UPLOADED:
- return 'pi-check-circle';
- case AssignStatus.ERROR:
- return 'pi-times-circle';
- default:
- return 'pi-question-circle';
- }
- }
-
- isStatusNew(status: AssignmentStatus): boolean {
- return status.state === AssignStatus.NEW;
- }
-
- /**
- * Check if a specific aircraft's assignment is in progress
- */
- isAircraftAssignmentInProgress(status: AssignmentStatus): boolean {
- return status.state === AssignStatus.NEW;
- }
-
- getStatusCssClass(status: AssignmentStatus): string {
- switch (status.state) {
- case AssignStatus.NEW:
- return 'new';
- case AssignStatus.DOWNLOADED:
- return 'downloaded';
- case AssignStatus.UPLOADED:
- return 'uploaded';
- case AssignStatus.ERROR:
- return 'error';
- default:
- return 'unknown';
- }
- }
-
- // ============================================================================
-}
diff --git a/Development/client/src/app/job/job-edit/job-edit.component.css b/Development/client/src/app/job/job-edit/job-edit.component.css
index abfca4f..1e94769 100644
--- a/Development/client/src/app/job/job-edit/job-edit.component.css
+++ b/Development/client/src/app/job/job-edit/job-edit.component.css
@@ -1,732 +1,3 @@
.sprayed-value {
margin-top: .25em;
-}
-
-/* Aircraft Item Layout */
-.aircraft-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 4px;
- min-height: 40px;
-}
-
-.aircraft-name {
- flex: 1;
- margin-right: 8px;
- font-weight: 500;
-}
-
-.aircraft-icon {
- color: #007ad9;
- font-size: 14px;
-}
-
-/* Satloc-specific Details */
-.satloc-details {
- margin-top: 4px;
- font-size: 11px;
- color: #666;
-}
-
-.tail-number {
- background-color: #f5f5f5;
- padding: 1px 4px;
- border-radius: 3px;
- margin-right: 6px;
- font-family: monospace;
-}
-
-/* Sync Status Indicators */
-.sync-status {
- margin-left: 4px;
-}
-
-.sync-status-active {
- color: #4caf50;
-}
-
-.sync-status-pending {
- color: #ff9800;
-}
-
-.sync-status-error {
- color: #f44336;
-}
-
-/* Package Status */
-.package-inactive {
- margin-left: 4px;
-}
-
-/* Hover Effects */
-.aircraft-item:hover {
- background-color: #f8f9fa;
- border-radius: 4px;
-}
-
-/* Aircraft item hover effects are now handled by global badge system */
-
-/* Download Options Styling */
-.download-options-info {
- display: flex;
- align-items: center;
- gap: 6px;
- margin-top: 4px;
- padding: 4px 8px;
- background-color: #e8f4fd;
- border: 1px solid #bbdefb;
- border-radius: 4px;
- font-size: 0.8rem;
- color: #1976d2;
- font-weight: 500;
-}
-
-.download-options-info .pi {
- font-size: 0.9rem;
- color: #1976d2;
-}
-
-/* Responsive adjustments for download options */
-@media (max-width: 768px) {
- .download-options-info {
- font-size: 0.75rem;
- padding: 3px 6px;
- }
-
- .download-options-info .pi {
- font-size: 0.8rem;
- }
-}
-
-@media (max-width: 480px) {
- .download-options-info {
- margin-top: 2px;
- padding: 2px 4px;
- }
-}
-
-/* Responsive Adjustments */
-@media (max-width: 768px) {
- .aircraft-item {
- flex-direction: column;
- align-items: flex-start;
- padding: 6px 4px;
- }
-
- .satloc-details {
- margin-top: 2px;
- }
-}
-
-
-/* Round Split Button Styling with ::ng-deep */
-:host ::ng-deep .assignment-action-button.slim .ui-splitbutton {
- width: 32px !important;
- height: 32px !important;
-}
-
-:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button {
- border-radius: 50% !important;
- width: 32px !important;
- height: 32px !important;
- padding: 0 !important;
- min-width: auto !important;
- background-color: #6c757d !important;
- border-color: #6c757d !important;
- color: white !important;
-}
-
-:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:hover {
- background-color: #5a6268 !important;
- border-color: #545b62 !important;
-}
-
-/* Hide the main button, show only dropdown arrow */
-:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-button:first-child {
- display: none !important;
-}
-
-/* Style the dropdown arrow button to be round */
-:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
- border-radius: 50% !important;
- width: 32px !important;
- height: 32px !important;
- border-left: none !important;
- padding: 0 !important;
- background-color: #6c757d !important;
- border-color: #6c757d !important;
- color: white !important;
-}
-
-:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton:hover {
- background-color: #5a6268 !important;
- border-color: #545b62 !important;
-}
-
-/* Override PrimeNG corner classes */
-:host ::ng-deep .assignment-action-button.slim .ui-corner-right {
- border-radius: 50% !important;
-}
-
-/* Center the dropdown arrow icon */
-:host ::ng-deep .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-left {
- margin: 0 !important;
- font-size: 0.8rem !important;
-}
-
-/* Assignment Status Display Styles (Update 1.1.4a) */
-.assignment-status-section {
- border: 1px solid #e0e0e0;
- border-radius: 6px;
- padding: 16px;
- background-color: #fafafa;
- margin-top: 15px;
-}
-
-.assignment-status-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- margin-bottom: 12px;
- padding-bottom: 8px;
- border-bottom: 1px solid #e0e0e0;
- gap: 16px;
-}
-
-.assignment-status-header h4 {
- margin: 0;
- color: #333;
- font-size: 1.1rem;
- font-weight: 600;
-}
-
-.clear-status-btn {
- padding: 4px 8px !important;
- min-width: auto !important;
- font-size: 0.8rem !important;
- background-color: #ffc107 !important;
- color: #666 !important;
-}
-
-.clear-status-btn:hover {
- background-color: #e0e0e0 !important;
- color: #333 !important;
-}
-
-.assignment-progress {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px;
- background-color: #e3f2fd;
- border: 1px solid #bbdefb;
- border-radius: 4px;
- margin-bottom: 12px;
- color: #1976d2;
- font-weight: 500;
-}
-
-.assignment-progress .pi-spinner {
- font-size: 1.2rem;
-}
-
-/* Assignment Status Polling Indicator */
-.polling-status-indicator {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- background-color: #e3f2fd;
- border: 1px solid #90caf9;
- border-radius: 4px;
- margin-bottom: 12px;
- color: #1565c0;
- font-size: 0.9rem;
- font-weight: 500;
-}
-
-.polling-status-indicator .pi-refresh {
- font-size: 1rem;
- color: #1976d2;
-}
-
-.polling-status-indicator small {
- margin-left: auto;
- color: #424242;
- font-weight: 400;
-}
-
-.assignment-error-summary {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px;
- background-color: #ffebee;
- border: 1px solid #ffcdd2;
- border-radius: 4px;
- margin-bottom: 12px;
- color: #c62828;
- font-weight: 500;
-}
-
-.assignment-error-summary .pi {
- font-size: 1.2rem;
-}
-
-.status-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.status-item {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- padding: 12px;
- border-radius: 6px;
- border: 1px solid #e0e0e0;
- background-color: white;
- transition: all 0.2s ease;
-}
-
-.status-item:hover {
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-.status-icon {
- flex-shrink: 0;
- width: 24px;
- display: flex;
- justify-content: center;
- align-items: center;
- margin-top: 2px;
-}
-
-.status-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.status-aircraft-info {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-
-.status-aircraft-info .aircraft-name {
- font-weight: 600;
- color: #333;
- font-size: 0.95rem;
-}
-
-.status-timestamp {
- font-size: 0.8rem;
- color: #666;
- white-space: nowrap;
-}
-
-.status-message {
- font-size: 0.9rem;
- color: #555;
-}
-
-.status-error-details {
- font-size: 0.8rem;
- color: #999;
- font-style: italic;
-}
-
-/* Status actions column centering and sizing */
-.status-actions {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center !important;
- width: 100% !important;
- margin: 0 auto !important;
-}
-
-/* Assignment Status Table Actions column centering */
-.assignment-status-table .status-actions {
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
- width: 100% !important;
- max-width: 56px !important;
- margin: 0 auto !important;
-}
-
-
-/* Ensure the assignment action button container is centered */
-.assignment-action-button.slim {
- display: inline-flex !important;
- align-items: center !important;
- justify-content: center !important;
-}
-
-.retry-btn {
- padding: 6px 12px !important;
- font-size: 0.8rem !important;
- min-width: auto !important;
- background-color: #ff9800 !important;
- border: 1px solid #f57c00 !important;
- color: white !important;
-}
-
-.retry-btn:hover:not(:disabled) {
- background-color: #f57c00 !important;
- border-color: #ef6c00 !important;
-}
-
-.retry-btn:disabled {
- opacity: 0.6 !important;
- cursor: not-allowed !important;
-}
-
-/* Enhanced Status Actions Menu Items */
-.p-menu .p-menuitem-link {
- font-size: 0.9rem !important;
- padding: 8px 12px !important;
-}
-
-.p-menu .p-menuitem-icon {
- margin-right: 8px !important;
- font-size: 0.85rem !important;
-}
-
-/* Split Button Menu Positioning */
-.status-actions .p-splitbutton .p-menu {
- min-width: 180px;
- margin-top: 2px;
-}
-
-/* Position dropdown menu slightly to the left to prevent cutoff at screen edge */
-:host ::ng-deep .assignment-action-button.slim .ui-menu {
- transform: translateX(-120px) !important;
- margin-top: 2px !important;
- min-width: 180px !important;
-}
-
-/* Alternative positioning for PrimeNG p-menu */
-:host ::ng-deep .assignment-action-button.slim .p-menu {
- transform: translateX(-120px) !important;
- margin-top: 2px !important;
- min-width: 180px !important;
-}
-
-/* Responsive adjustments for split buttons */
-@media (max-width: 768px) {
-
- .retry-split-button .p-button,
- .status-split-button .p-button {
- padding: 4px 8px !important;
- font-size: 0.75rem !important;
- }
-
- .status-actions .p-splitbutton .p-menu {
- min-width: 160px;
- }
-}
-
-/* Status State Specific Styles */
-.status-pending .status-icon {
- color: #ff9800;
-}
-
-.status-retrying .status-icon {
- color: #ff9800;
-}
-
-.status-success {
- border-color: #c8e6c9;
- background-color: #f1f8e9;
-}
-
-.status-success .status-icon {
- color: #4caf50;
-}
-
-.status-error {
- border-color: #ffcdd2;
- background-color: #ffebee;
-}
-
-.status-error .status-icon {
- color: #f44336;
-}
-
-/* Responsive Design for Assignment Status */
-@media (max-width: 768px) {
- .assignment-status-section {
- padding: 12px;
- }
-
- .assignment-status-header {
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
- }
-
- .assignment-status-header h4 {
- font-size: 1rem;
- }
-
- .status-item {
- padding: 10px;
- gap: 8px;
- }
-
- .status-aircraft-info {
- flex-direction: column;
- align-items: flex-start;
- gap: 4px;
- }
-
- .status-aircraft-info .aircraft-name {
- font-size: 0.9rem;
- }
-
- .status-timestamp {
- font-size: 0.75rem;
- }
-
- .status-message {
- font-size: 0.85rem;
- }
-
- .retry-btn {
- padding: 4px 8px !important;
- font-size: 0.75rem !important;
- }
-}
-
-@media (max-width: 480px) {
- .status-item {
- flex-direction: column;
- gap: 8px;
- }
-
- .status-icon {
- align-self: flex-start;
- }
-
- .status-actions {
- align-self: flex-end;
- max-width: 48px !important;
- }
-
- .assignment-progress {
- padding: 8px;
- }
-
- .assignment-error-summary {
- padding: 8px;
- }
-}
-
-/* Assignment Status Table Styling (Update 1.1.4b) */
-.assignment-status-table {
- margin-top: 10px;
-}
-
-.assignment-status-table .ui-table-tbody>tr {
- border-left: 4px solid transparent;
-}
-
-.assignment-status-table .status-row-pending {
- border-left-color: #2196f3;
-}
-
-.assignment-status-table .status-row-success {
- border-left-color: #4caf50;
-}
-
-.assignment-status-table .status-row-error {
- border-left-color: #f44336;
-}
-
-.assignment-status-table .status-row-retrying {
- border-left-color: #ff9800;
-}
-
-.aircraft-cell {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.aircraft-cell .pi {
- font-size: 0.9rem;
-}
-
-.status-badge {
- display: inline-block;
- padding: 2px 8px;
- border-radius: 4px;
- font-size: 0.75rem;
- font-weight: 500;
- text-transform: uppercase;
- width: fit-content;
-}
-
-.status-badge-pending {
- background-color: #e3f2fd;
- color: #1976d2;
-}
-
-.status-badge-success {
- background-color: #e8f5e8;
- color: #2e7d32;
-}
-
-.status-badge-error {
- background-color: #ffebee;
- color: #c62828;
-}
-
-.status-badge-retrying {
- background-color: #fff3e0;
- color: #f57c00;
-}
-
-.status-message {
- font-weight: 500;
-}
-
-.status-error-details {
- margin-top: 4px;
- color: #666;
-}
-
-.status-timestamp {
- font-size: 0.85rem;
- color: #666;
-}
-
-.empty-message {
- text-align: center;
- padding: 20px;
- color: #666;
- font-style: italic;
-}
-
-/* Responsive design for table */
-@media (max-width: 768px) {
- .assignment-status-table .ui-table-thead {
- display: none;
- }
-
- .assignment-status-table .ui-table-tbody>tr>td {
- display: block;
- border: none;
- border-bottom: 1px solid #ddd;
- padding: 6px;
- }
-
- .assignment-status-table .ui-table-tbody>tr>td:before {
- content: attr(data-label) ": ";
- font-weight: bold;
- display: inline-block;
- width: 80px;
- }
-}
-
-
-/* Update 1.1.6a: Slim Split Button Actions Styling */
-
-/* Slim Split Button for Assignment Status Table */
-.assignment-action-button.slim .ui-splitbutton {
- width: 32px !important;
- height: 32px !important;
-}
-
-.assignment-action-button.slim .ui-splitbutton .ui-button {
- width: 32px !important;
- height: 32px !important;
- border-radius: 50% !important;
- padding: 0 !important;
- min-width: auto !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
- background-color: #6c757d !important;
- border-color: #6c757d !important;
- color: white !important;
-}
-
-.assignment-action-button.slim .ui-splitbutton .ui-button:hover {
- background-color: #5a6268 !important;
- border-color: #545b62 !important;
-}
-
-.assignment-action-button.slim .ui-splitbutton .ui-button:focus {
- outline: none !important;
- box-shadow: 0 0 0 2px rgba(108, 117, 125, 0.5) !important;
-}
-
-/* Hide the left button for slim style - enhanced for assignment status */
-.assignment-action-button.slim .ui-splitbutton .ui-button:first-child {
- display: none !important;
-}
-
-/* Style the dropdown arrow button */
-.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
- width: 32px !important;
- height: 32px !important;
- border-radius: 50% !important;
- border-left: none !important;
- padding: 0 !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
-}
-
-.assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton .ui-button-icon-primary {
- margin: 0 !important;
- font-size: 1rem !important;
-}
-
-/* Status indicator text for pending/retrying states */
-.status-indicator-text {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 0.8rem;
- color: #666;
- font-style: italic;
-}
-
-.status-indicator-text .pi {
- font-size: 0.9rem;
-}
-
-.status-indicator-text .pi-spin {
- color: #ff9800;
-}
-
-.status-indicator-text .pi-clock {
- color: #2196f3;
-}
-
-/* Responsive adjustments */
-@media (max-width: 768px) {
- .assignment-action-button.slim .ui-splitbutton {
- width: 28px !important;
- height: 28px !important;
- }
-
- .assignment-action-button.slim .ui-splitbutton .ui-button,
- .assignment-action-button.slim .ui-splitbutton .ui-splitbutton-menubutton {
- width: 28px !important;
- height: 28px !important;
- }
-
- .status-indicator-text {
- font-size: 0.75rem;
- }
}
\ No newline at end of file
diff --git a/Development/client/src/app/job/job-edit/job-edit.component.html b/Development/client/src/app/job/job-edit/job-edit.component.html
index 4ebd8c9..55e0b95 100644
--- a/Development/client/src/app/job/job-edit/job-edit.component.html
+++ b/Development/client/src/app/job/job-edit/job-edit.component.html
@@ -18,7 +18,8 @@
@@ -426,8 +427,31 @@
-
-
+
+
+
+
+
+
+
+ {{ user.name }}
+
+
+
+
+
+
Download Options :
+
+
+
+
+
+
+
+
diff --git a/Development/client/src/app/job/job-edit/job-edit.component.ts b/Development/client/src/app/job/job-edit/job-edit.component.ts
index f8c5cd9..700ff4e 100644
--- a/Development/client/src/app/job/job-edit/job-edit.component.ts
+++ b/Development/client/src/app/job/job-edit/job-edit.component.ts
@@ -41,6 +41,7 @@ import { selectLimit } from '@app/reducers';
import { Acre } from '@app/domain/models/subscription.model';
import { SUB, SubTexts, SubType } from '@app/profile/common';
+
@Component({
selector: 'agm-job-edit',
templateUrl: './job-edit.component.html',
@@ -104,6 +105,9 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
grpedProds: SelectItemGroup[] = [];
+ srcUsers: any[];
+ tarUsers: any[];
+
uploadUrl = '/imports/uploadJob';
uploadedFiles = [];
dlLogs = [];
@@ -401,6 +405,9 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
this.resetNewEntities();
this.checkOKDl();
}));
+ this.sub$.add(this.appActions.ofType(jobActions.ASSIGN_SUCCESS).subscribe((action) => {
+ this._job['dlOp'] = this.selectedItem.dlOp;
+ }));
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).pipe(take(1)).subscribe((pkg) => {
this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
@@ -434,6 +441,9 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
if (this.isEdit) {
this.getUploadedFiles();
this.getLogs();
+ if (this.isPlanner) {
+ this.getAssignments();
+ }
}
}, 500);
@@ -477,6 +487,15 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
});
}
+ private getAssignments() {
+ this.jobSvc.getAssignments({ 'jobId': this.job._id }).subscribe((res) => {
+ if (res) {
+ this.srcUsers = !Utils.isEmptyArray(res.avUsers) ? res.avUsers.filter(u => u.pkgActive) : [];
+ this.tarUsers = res.asUsers;
+ }
+ });
+ }
+
private getAppRateUnits(isUS: boolean) {
if (isUS) {
this.rateUnits = [
@@ -531,6 +550,19 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
return valid;
}
+ onMoveToActiveList(items) {
+ if (items && items.length) {
+ const inactiveACList = items.filter(i => i.active === false);
+ if (inactiveACList.length) {
+ this.tarUsers = [...this.tarUsers, ...inactiveACList];
+ this.srcUsers = this.srcUsers.filter(u => u.active === true);
+ let errMsg = $localize`:@@cannotUnAssignInactiveVehicles:Cannot unassign inactive Aircraft`;
+ errMsg += ':[ ' + (inactiveACList.map(u => u.name)).join(',') + ' ]';
+ this.msgSvc.addFailedMsg(errMsg);
+ }
+ }
+ }
+
getUserToolTip(user) {
if (Utils.isEmptyObj(user)) return '';
let userTT = user.username;
@@ -654,22 +686,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
}
onStatusChanged(event) {
- const oldStatus = this.selectedItem.status; // Current status before change
- const newStatus = event.value; // New status from dropdown
-
- // Track job status change with GA4
- this.gaSvc.trackJobStatusChanged({
- user_id: this.authSvc.user?._id || 'anonymous',
- platform: 'web',
- job_id: this.selectedItem._id?.toString() || 'unknown',
- old_status: this.mapStatusToString(oldStatus),
- new_status: this.mapStatusToString(newStatus),
- status_change_reason: 'user_action',
- completion_time: newStatus === 3 ? new Date().toISOString() : undefined,
- efficiency_score: this.calculateEfficiencyScore(oldStatus, newStatus)
- });
-
- // Existing logic
if (this.isEndStatus(event.value)) {
if (!this.selectedItem.endDate) {
this.selectedItem.endDate = new Date();
@@ -689,38 +705,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
return [1, 2, 3].includes(status) && this.selectedItem.status > 0;
}
- /**
- * Map numeric status to string for GA4 tracking
- */
- private mapStatusToString(status: number): 'new' | 'ready' | 'downloaded' | 'sprayed' | 'archived' {
- switch (status) {
- case 0: return 'new';
- case 1: return 'ready';
- case 2: return 'downloaded';
- case 3: return 'sprayed';
- case 9: return 'archived';
- default: return 'new';
- }
- }
-
- /**
- * Calculate efficiency score based on status transition
- */
- private calculateEfficiencyScore(oldStatus: number, newStatus: number): number {
- // Simple efficiency scoring based on forward progression
- if (newStatus > oldStatus && newStatus !== 9) {
- // Forward progression (positive)
- return Math.min(100, 70 + (newStatus - oldStatus) * 10);
- } else if (newStatus < oldStatus && oldStatus !== 9) {
- // Backward progression (less efficient)
- return Math.max(30, 50 - (oldStatus - newStatus) * 10);
- } else if (newStatus === 9) {
- // Archived status
- return oldStatus >= 3 ? 90 : 60; // High if completed work, lower if archived early
- }
- return 50; // Default/neutral score
- }
-
onUnitChanged(event) {
if (event) {
this.updateUnits(this.selectedItem.measureUnit);
@@ -755,6 +739,23 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
}
}
+ downLoadJob(type: number) {
+ this.doDownLoadJob(type);
+ }
+
+ private doDownLoadJob(type) {
+ // TODO: Need to be handled in effects ???
+ this.jobSvc.downloadJob({ jobId: this.selectedItem._id, type: type }).subscribe(
+ (res) => {
+ try {
+ saveAs(res, `${this.selectedItem.name}_${this.selectedItem._id}.zip`);
+ } catch (error) {
+ alert('Sorry. Your browser does not support this feature !');
+ }
+ this.getLogs();
+ });
+ }
+
editJobMap(id?: number) {
this.router.navigate(
[
@@ -766,17 +767,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
onSelectUpload(event) {
this.uploadErrorMsg = '';
if (this.uploader.hasFiles()) {
- // Track file upload start using GA4 convention
- const files = this.uploader.files;
- files.forEach(file => {
- this.gaSvc.trackFileUploadStarted({
- file_type: this.gaHelpers.determineFileType(file.name),
- file_size_mb: Number((file.size / (1024 * 1024)).toFixed(2)),
- related_job_id: this.job?._id?.toString(),
- upload_source: 'manual',
- platform: 'web'
- });
- });
this.uploader.upload();
}
}
@@ -797,19 +787,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
if (res && res['_id']) {
this.curAppId = res['_id'];
this.checkImportStatus(this.curAppId);
- // Track successful file upload using GA4 convention
- const fileType = this.uploader.files && this.uploader.files.length > 0
- ? this.gaHelpers.determineFileType(this.uploader.files[0].name)
- : 'prescription_map'; // Default fallback for job context
- this.gaSvc.trackFileUploadCompleted({
- file_size_mb: 0,
- file_type: fileType,
- related_job_id: this.job?._id?.toString(),
- upload_source: 'manual',
- processing_time_seconds: 0,
- validation_status: 'passed',
- platform: 'web'
- });
+ this.gaSvc.gaEvent('JOBS', 'DATA', 'U');
}
}
}
@@ -818,22 +796,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
if (event && event.error) {
const resp = event.error;
const status = resp.status;
-
- // Track file upload failure using GA4 convention
- if (this.uploader.files && this.uploader.files.length > 0) {
- const file = this.uploader.files[0];
- this.gaSvc.trackFileUploadFailed({
- file_type: this.gaHelpers.determineFileType(file.name),
- file_size_mb: Number((file.size / (1024 * 1024)).toFixed(2)),
- related_job_id: this.job?._id?.toString(),
- upload_source: 'manual',
- error_type: status === 401 ? 'authentication_error' : 'server_error',
- error_message: resp.error?.['error']?.['.tag'] || 'Upload failed',
- retry_attempted: false,
- platform: 'web'
- });
- }
-
if (status === 401) {
this.store.dispatch(new authActions.Logout);
} else if (status > 400) {
@@ -986,20 +948,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
this.jobSvc.deleteAppFile({ appId: appFile.id }).subscribe((data) => {
if (data['appId']) {
this.uploadedFiles = this.uploadedFiles.filter(it => it.id !== data['appId']);
-
- // Track file deletion using GA4 convention
- const fileType = appFile.fileName
- ? this.gaHelpers.determineFileType(appFile.fileName)
- : 'prescription_map'; // Default fallback for job context
- this.gaSvc.trackFileDeleted({
- file_type: fileType,
- file_size_mb: 0, // File size not available in appFile object
- related_job_id: this.job?._id?.toString(),
- deletion_reason: 'user_action',
- file_age_days: appFile.when ? Math.floor((Date.now() - new Date(appFile.when).getTime()) / (1000 * 60 * 60 * 24)) : undefined,
- confirmation_required: true,
- platform: 'web'
- });
}
this.updateTotalCoverage();
});
@@ -1007,51 +955,18 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
});
}
- // Assignment functionality moved to job-assignment component
+ assignJob() {
+ if (!this.job) {
+ return;
+ }
- downLoadJob(type: number) {
- this.doDownLoadJob(type);
- }
-
- private doDownLoadJob(type) {
- // TODO: Need to be handled in effects ???
- this.jobSvc.downloadJob({ jobId: this.selectedItem._id, type: type }).subscribe(
- (data) => {
- this.okDl = true;
- try {
- saveAs(data, this.selectedItem.name + '.zip');
-
- // Track job download using GA4 convention
- this.gaSvc.trackFileDownloaded({
- file_type: 'prescription_map',
- file_size_mb: 0, // Size not available from response
- related_job_id: this.selectedItem._id?.toString(),
- download_method: 'button_click',
- file_format: 'original',
- download_source: 'job_edit',
- platform: 'web'
- });
- } catch (error) {
- console.error('Download failed:', error);
- alert('Sorry. Your browser does not support this feature !');
- }
- },
- (error) => {
- console.error('Download job failed:', error);
- this.msgSvc.addFailedMsg('Failed to download job');
- }
- );
- }
-
- // Event handlers for job assignment component
- onAssignmentComplete(event: any): void {
- console.log('Assignment completed:', event);
- // Handle assignment completion if needed
- }
-
- onAssignmentError(error: any): void {
- console.error('Assignment error:', error);
- // Handle assignment error if needed
+ const assignment = {
+ jobId: this.job._id,
+ dlOp: this.selectedItem.dlOp,
+ avUsers: this.srcUsers,
+ asUsers: this.tarUsers
+ };
+ this.store.dispatch(new jobActions.Assign(assignment));
}
downloadAppfile(data) {
@@ -1059,20 +974,6 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
(res) => {
try {
saveAs(res, data.name);
-
- // Track file download using GA4 convention
- const fileType = data.name
- ? this.gaHelpers.determineFileType(data.name)
- : 'prescription_map'; // Default fallback for job context
- this.gaSvc.trackFileDownloaded({
- file_type: fileType,
- file_size_mb: data.size ? this.parseFileSizeToMB(data.size) : 0,
- related_job_id: this.job?._id?.toString(),
- download_method: 'button_click',
- file_format: 'original',
- download_source: 'job_edit',
- platform: 'web'
- });
} catch (error) {
alert('Sorry. Your browser does not support this feature !');
}
@@ -1373,30 +1274,7 @@ export class JobEditComponent extends BaseComp implements OnInit, AfterViewInit,
this.addingNewCropJob = evt == 1;
}
- /**
- * Parse file size string to megabytes for GA4 tracking
- */
- private parseFileSizeToMB(sizeString: string): number {
- if (!sizeString) return 0;
-
- // Handle formats like "2.3 MB", "1.5 KB", "500 B"
- const match = sizeString.match(/(\d+\.?\d*)\s*(B|KB|MB|GB)/i);
- if (!match) return 0;
-
- const value = parseFloat(match[1]);
- const unit = match[2].toUpperCase();
-
- switch (unit) {
- case 'B':
- return value / (1024 * 1024);
- case 'KB':
- return value / 1024;
- case 'MB':
- return value;
- case 'GB':
- return value * 1024;
- default:
- return 0;
- }
+ ngOnDestroy(): void {
+ super.ngOnDestroy();
}
}
diff --git a/Development/client/src/app/job/job-list/job-list.component.html b/Development/client/src/app/job/job-list/job-list.component.html
index a83eeed..ebdc3c9 100644
--- a/Development/client/src/app/job/job-list/job-list.component.html
+++ b/Development/client/src/app/job/job-list/job-list.component.html
@@ -1,11 +1,7 @@
@@ -44,10 +37,8 @@
{{cols[1].header}} {{job._id}}
{{cols[2].header}} {{job.orderNumber}}
{{cols[3].header}} {{job.name}}
-
{{cols[4].header}} {{job.startDate |
- date:'shortDate'}}
-
{{cols[5].header}} {{job.endDate |
- date:'shortDate'}}
+
{{cols[4].header}} {{job.startDate | date:'shortDate'}}
+
{{cols[5].header}} {{job.endDate | date:'shortDate'}}
{{cols[5].header}}
{{ job.status | jobStatus }}
@@ -61,24 +52,15 @@
@@ -91,28 +73,22 @@
Filter Jobs By Created Date
-
+
-
+
\ No newline at end of file
diff --git a/Development/client/src/app/job/job-list/job-list.component.ts b/Development/client/src/app/job/job-list/job-list.component.ts
index 030e835..f639806 100644
--- a/Development/client/src/app/job/job-list/job-list.component.ts
+++ b/Development/client/src/app/job/job-list/job-list.component.ts
@@ -27,7 +27,6 @@ import { SUB, SubTexts, SubType } from '@app/profile/common';
import { InvoiceService } from '@app/domain/services/invoice.service';
import { RestoreTableState } from '@app/shared/restore-table-state';
import { SubscriptionService } from '@app/domain/services/subscription.service';
-import { GAService } from '@app/shared/ga.service';
@Component({
@@ -85,8 +84,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
private readonly datePipe: DatePipe,
private readonly invoiceSvc: InvoiceService,
private readonly restoreTableSvc: RestoreTableState,
- private readonly subscriptionService: SubscriptionService,
- private readonly gaService: GAService
+ private readonly subscriptionService: SubscriptionService
) {
super();
this.currClient = ({ label: globals.all, value: null });
@@ -135,7 +133,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
ngOnInit() {
- // Initialize subscriptions first to get accurate data
this.sub$ = this.store.pipe(select(fromClients.getAllClients)).subscribe(clients => {
if (Utils.isEmptyArray(clients)) {
return;
@@ -154,7 +151,8 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
} else {
this.currClient = ({ label: globals.all, value: null });
}
- })); this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
+ }));
+ this.sub$.add(this.store.pipe(select(fromJobs.getJobsByClient)).subscribe(jobs => {
this.jobs = jobs;
}));
this.sub$.add(this.store.pipe(select(fromJobs.getSelectedJob)).subscribe((job) => {
@@ -162,27 +160,11 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}));
this.sub$.add(this.store.select(selectLimit(SubType.PACKAGE)).subscribe((pkg) => {
- if (pkg) {
- const lookupKey = this.authSvc.getCurLookupKey(SubType.PACKAGE);
-
- // If lookup key is empty (user data not loaded yet), find first package key
- let effectiveLookupKey = lookupKey;
- if (!lookupKey && pkg) {
- const packageKeys = Object.keys(pkg);
- if (packageKeys.length > 0) {
- effectiveLookupKey = packageKeys[0]; // Use first available package
- }
- }
-
- this.acre = pkg[effectiveLookupKey]?.acre;
- }
+ if (pkg) this.acre = pkg[this.authSvc.getCurLookupKey(SubType.PACKAGE)]?.acre;
}));
}
ngAfterViewInit(): void {
- // Track job list viewed ONCE when component is fully initialized
- this.trackJobListViewedEvent();
-
const listFilter = sessionStorage.getItem('jtb-ops') ? JSON.parse(sessionStorage.getItem('jtb-ops')) : null;
if (listFilter?.filters) {
@@ -222,21 +204,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}, 100);
}
- private trackJobListViewedEvent(): void {
- // Track agricultural business intelligence (complements automatic page_view)
- this.gaService.trackJobListViewed({
- user_id: this.authSvc.user?._id || 'anonymous',
- platform: 'web',
- view_type: 'table',
- total_jobs: this.jobs?.length || 0,
- displayed_jobs: this.jobs?.length || 0,
- sort_by: this.dt?.sortField || null,
- filter_count: this.getActiveFilterCount(),
- client_filter_applied: !!this.currClient?.value,
- reload_interval: this.reloadBy
- });
- }
-
restoreStatusState(status, invoiced) {
const statusMap = {
0: jobListStatus.NEW,
@@ -284,27 +251,9 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
onRowSelect(event) {
this.store.dispatch(new jobActions.Select(this.currentJob));
-
- // Track job selection
- if (this.currentJob) {
- const positionInList = this.jobs.findIndex(job => job._id === this.currentJob._id) + 1;
-
- this.gaService.trackJobSelected({
- user_id: this.authSvc.user?._id || 'anonymous',
- platform: 'web',
- job_id: this.currentJob._id.toString(),
- selection_method: 'row_click',
- position_in_list: positionInList,
- job_type: this.currentJob.appType || 'unknown',
- job_status: this.currentJob.status?.toString() || 'unknown'
- });
- }
}
get canAddNew(): boolean {
- // Check subscription package loaded (!!this.acre) and not over limit
- // Note: With unlimited acres (limit: null), overLimit will always be false,
- // but keep this check for defensive programming in case limited plans return
return !!this.acre && !this.acre.overLimit;
}
@@ -327,16 +276,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
duplicateJob() {
if (this.canAddNew) {
- // Track bulk action (duplicate)
- this.gaService.trackJobBulkAction({
- user_id: this.authSvc.user?._id || 'anonymous',
- platform: 'web',
- action_type: 'duplicate',
- job_count: 1,
- job_ids: [this.currentJob._id.toString()],
- success_rate: 1.0
- });
-
return this.router.navigate([`./${this.currentJob._id}/edit`, { dup: true }], { relativeTo: this.route });
}
return this.displaySubDia();
@@ -363,26 +302,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
reloadJobs() {
- const startTime = performance.now();
-
this.fetchJobsByClient(this.currClient && this.currClient.value);
-
- // Track job list reload
- setTimeout(() => {
- const endTime = performance.now();
- this.gaService.trackJobListViewed({
- user_id: this.authSvc.user?._id || 'anonymous',
- platform: 'web',
- view_type: 'table',
- total_jobs: this.jobs?.length || 0,
- displayed_jobs: this.jobs?.length || 0,
- sort_by: this.dt?.sortField || null,
- filter_count: this.getActiveFilterCount(),
- load_time_ms: Math.round(endTime - startTime),
- client_filter_applied: !!this.currClient?.value,
- reload_interval: this.reloadBy
- });
- }, 100);
}
reloadChanged(value) {
@@ -437,8 +357,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
handleStatusFilter(value) {
- const previousCount = this.jobs?.length || 0;
-
switch (value) {
case jobListStatus.ALL:
this.dt.filter(null, 'status', 'equals');
@@ -471,20 +389,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
this.statusFilter = jobListStatus.INVOICED;
break;
}
-
- // Track filter usage
- setTimeout(() => {
- const currentCount = this.jobs?.length || 0;
- this.gaService.trackJobListFiltered({
- user_id: this.authSvc.user?._id || 'anonymous',
- platform: 'web',
- filter_type: 'status',
- filter_value: value,
- results_before: previousCount,
- results_after: currentCount,
- filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0
- });
- }, 100);
}
private setJobListSelDate(dateSelection): void {
@@ -506,34 +410,15 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
}
onDropdownChange(evt): void {
- const previousCount = this.jobs?.length || 0;
-
if (evt.value === this.customeDate) {
setTimeout(() => this.showCal());
} else {
this.setJobListSelDate({ selDate: evt.value, selCalDate: null });
this.reloadJobs();
-
- // Track date filter usage
- setTimeout(() => {
- const currentCount = this.jobs?.length || 0;
- this.gaService.trackJobListFiltered({
- user_id: this.authSvc.user?._id || 'anonymous',
- platform: 'web',
- filter_type: 'date',
- filter_value: evt.value,
- results_before: previousCount,
- results_after: currentCount,
- filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
- date_filter_type: this.getDateFilterType(evt.value)
- });
- }, 500);
}
}
onCalClose(): void {
- const previousCount = this.jobs?.length || 0;
-
this.setCustomDateLabel();
if (this.selCalDate) {
this.setJobListSelDate({ selDate: null, selCalDate: this.selCalDate });
@@ -542,27 +427,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
this.setJobListSelDate({ selDate: this.selDate, selCalDate: null });
}
this.reloadJobs();
-
- // Track custom date filter usage
- if (this.selCalDate) {
- setTimeout(() => {
- const currentCount = this.jobs?.length || 0;
- this.gaService.trackJobListFiltered({
- user_id: this.authSvc.user?._id || 'anonymous',
- platform: 'web',
- filter_type: 'date',
- filter_value: 'custom_date_range',
- results_before: previousCount,
- results_after: currentCount,
- filter_effectiveness: previousCount > 0 ? (currentCount / previousCount) : 0,
- date_filter_type: 'custom',
- custom_date_range: [
- this.selCalDate[0]?.toISOString().split('T')[0],
- this.selCalDate[1]?.toISOString().split('T')[0]
- ]
- });
- }, 500);
- }
}
onCalClick() {
@@ -577,48 +441,6 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit,
return item?.value == this.customeDate && this.selDate == this.customeDate
}
- // Helper method to count active filters
- private getActiveFilterCount(): number {
- let count = 0;
-
- // Check status filter
- if (this.statusFilter && this.statusFilter !== jobListStatus.ALL) {
- count++;
- }
-
- // Check client filter
- if (this.currClient?.value) {
- count++;
- }
-
- // Check date filter
- if (this.selDate && this.selDate !== this.dateOptions[0]?.value) {
- count++;
- }
-
- // Check table column filters
- if (this.dt?.filters) {
- Object.keys(this.dt.filters).forEach(key => {
- const filter = this.dt.filters[key];
- if (filter && filter.value && filter.value !== '') {
- count++;
- }
- });
- }
-
- return count;
- }
-
- // Helper method to determine date filter type
- private getDateFilterType(value: string): 'today' | 'week' | 'month' | 'quarter' | 'custom' {
- if (value === this.customeDate) return 'custom';
- if (value?.includes('today')) return 'today';
- if (value?.includes('week')) return 'week';
- if (value?.includes('month')) return 'month';
- if (value?.includes('quarter')) return 'quarter';
- return 'custom';
- }
-
ngOnDestroy() {
super.ngOnDestroy();
if (this.reload$) {
diff --git a/Development/client/src/app/job/job-map-edit/job-map-edit.component.html b/Development/client/src/app/job/job-map-edit/job-map-edit.component.html
index b7dfcba..2532c1b 100644
--- a/Development/client/src/app/job/job-map-edit/job-map-edit.component.html
+++ b/Development/client/src/app/job/job-map-edit/job-map-edit.component.html
@@ -678,7 +678,7 @@
{{curPlayRec.timeLocal || "00:00:00.0"}}
-
@@ -700,18 +700,14 @@
{{playXt.avg | length:isUS:0 }} / {{curPlayRec.xt | xtract:isUS:0}}
TrckAngle
{{curPlayRec.trckAngle}}
-
- LckedLine
- {{curPlayRec.lockedLine | lockline:curPlayLoc?.xTrack }}
-
+ LckedLine
+ {{curPlayRec.lockedLine | lockline:curPlayLoc?.xTrack }}
HDOP
{{curPlayRec.hdop}}
Sat/Cor/ID
{{curPlayRec.sats || 0}} / {{curPlayRec.corId || 0}}/ {{curPlayRec.waasId}}
-
- SprayStat
- {{curPlayLoc?.sprayStat}} (DEBUG)
-
+ SprayStat
+ {{curPlayLoc?.sprayStat}}
@@ -720,14 +716,8 @@
Applic.RateAp
{{ curPlayRec.appRateAp | appRate:playMatType:isUS:null:false }}
Applic.RateRq
-
- {{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}
-
-
- {{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}
-
- FlowRateAp
-
+ {{ curPlayRec.applicRate | appRate:playMatType:isUS:curPlayRec.applicRateUnit:false }}
+ FlowRateAp
{{curPlayRec.flowRateAp || 0 | flowRate:isUS }}
FlowRateRq
{{curPlayRec.flowRateRq || 0 | flowRate:isUS }}
@@ -742,9 +732,9 @@
Flow Control
- {{curPlayRec.flowControl }}
+ {{curPlayRec.flowControl || "No FC" }}
-
+
Bm Pressure
{{curPlayRec.bmPressure | number:'1.1-1':'en'}} psi
@@ -783,10 +773,8 @@
AutoSpr On/Off
{{curPlayRec.sprOnLag | number:'1.2-2':'en' }} / {{curPlayRec.sprOffLag | number:'1.2-2':'en'}}
-
- Pulses/Liter
- {{curPlayRec.pulsesPLiter | number:'1.0-0':'en'}}
-
+ Pulses/Liter
+ {{curPlayRec.pulsesPLiter | number:'1.0-0':'en'}}
@@ -807,10 +795,8 @@
{{NumUtils.fixedTo(curPlayRec.utmY, 1, '0.0')}}
Speed
{{UnitUtils.mpsToKnot(curPlayRec.speed) | number:'1.1-1':'en'}} knots
-
- LckedLine
- {{curPlayRec.lockedLine | lockline}}
-
+ LckedLine
+ {{curPlayRec.lockedLine | lockline}}
Wind Spd
{{curPlayRec.windSpd | number:'1.1-1':'en' }} knots
Wind Dir
@@ -837,10 +823,8 @@
-
- AreaName
- {{curPlayRec.areaName}}
-
+
AreaName
+
{{curPlayRec.areaName}}
Mapped Area
{{curPlayRec.mappedArea | number:'1.1-1':'en' }} {{ currentJob.measureUnit | areaUnit:false }}
AreaSprTot
@@ -850,13 +834,7 @@
Pilot Name
{{curPlayRec.pilotName}}
Applic.Rate
-
-
- {{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}
-
-
- {{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}
-
+
{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:null:false }}
Mat Needed
{{( totalAmount?.value || 0) | number:'1.1-1':'en'}} {{ totalAmount?.appRateUnit | rateUnit:1:false }}
Mat Sprayed
@@ -867,4 +845,4 @@
-
\ No newline at end of file
+
diff --git a/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts b/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts
index e10ee2e..d4fdf05 100644
--- a/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts
+++ b/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts
@@ -26,7 +26,7 @@ import { IJob, Area, WayPoint, ITEM, BufferZone, RptOption, WeatherInfo, defWeat
import * as jobActions from '../actions/job.actions';
import { UpdateJobOps } from '../actions/job.actions';
-import { RoleIds, globals, DRAW, KEY_CODE, PANE, GC, MatType, RateUnit, SysDataTypes, MatType2 } from '@app/shared/global';
+import { RoleIds, globals, DRAW, KEY_CODE, PANE, GC, MatType, RateUnit } from '@app/shared/global';
import { LengthUnitPipe } from '@app/shared/pipes/length-unit.pipe';
import { JobService } from '@app/domain/services/job.service';
import { ObstacleService } from '@app/domain/services/obstacle.service';
@@ -179,13 +179,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
private lastPlayUnit;
// {: { }}, = { data: [], other fields }
filesDataSet = {};
- // Track pagination state per file
- private fileDataPagination: Map = new Map();
playIdx: number = -1;
centerPlayPos: boolean = false;
playMarker: any;
@@ -237,14 +230,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
return this.job;
}
- get isPlayingAgNavFile(): boolean {
- return Boolean(this.playingFile && this.playingFile?.file?.meta && this.playingFile?.file?.meta?.type === SysDataTypes.AGNAV);
- }
-
- get isPlayingSatLocFile(): boolean {
- return Boolean(this.playingFile && this.playingFile?.file?.meta && this.playingFile?.file?.meta?.type === SysDataTypes.SATLOC);
- }
-
protected postUpdateDrawToolTips(type: DRAW) {
switch (type) {
case DRAW.BUFFER:
@@ -325,6 +310,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
private readonly weatherSvc: WeatherService,
private readonly ngZone: NgZone,
protected cdRef: ChangeDetectorRef,
+
) {
super(cdRef);
@@ -605,7 +591,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
label: globals.sprayZone, icon: '', command: () => {
this.confirmSvc.confirm({
header: SubTexts.textUpgradeSub,
- message: $localize`:@@upgradeSprayZone:You have exceeded the permitted limit for the maximum applicable area. Please upgrade your subscription to enable this feature.`,
+ message: $localize`:@@upgradeSprayZone:You have exceeded allowable acre limit. Please upgrade your subscription to enable this feature.`,
accept: () => {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
@@ -2642,7 +2628,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this.map && this.map.removeLayer(this.playMarker);
this.playMarker = null;
}
-
}
private findRefZone() {
@@ -2700,7 +2685,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
if (!file.meta) {
file.meta = { appRate: this.job.appRate, rateUnit: this.job.appRateUnit, hasQfile: false, useFC: false };
} else {
- file.meta.useFC = (file.meta.fcType && typeof file.meta.fcType === 'string' && file.meta.fcType.length && !file.meta.fcType.match(/none/i)) ? true : false;
+ file.meta.useFC = (file.meta.fcType && file.meta.fcType.length && !file.meta.fcType.match(/none/i)) ? true : false;
file.rateUnit = file.meta.appRateUnitStr ? UnitUtils.rateStringToCode(file.meta.appRateUnitStr, this.isUS) : this.job.appRateUnit;
}
}
@@ -2715,8 +2700,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this.selDataFiles = [];
this.dataFiles = [];
this.filesDataSet = {};
- // Reset loaded file pagination data
- this.fileDataPagination.clear();
}
private updatePlayRecord(idx: number, forward: boolean) {
@@ -2728,13 +2711,8 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
const newRec = new PlayRecord();
newRec.timeGPS = this.curPlayLoc.gpsTime;
- if (newRec.timeGPS) {
- if (this.isPlayingAgNavFile)
- newRec.timeLocal = DateUtils.msToTime(newRec.timeGPS * 1000, this.localTz);
- else {
- newRec.timeLocal = DateUtils.gpsTimeToLocalISO(this.curPlayLoc.gpsTime, this.curPlayLoc.gmtOffset || 0);
- }
- }
+ if (newRec.timeGPS)
+ newRec.timeLocal = DateUtils.msToTime(newRec.timeGPS * 1000, this.localTz);
newRec.lat = this.curPlayLoc.lat;
newRec.lon = this.curPlayLoc.lon;
@@ -2764,24 +2742,26 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
newRec.flowRateRq = this.curPlayLoc.lminReq;
- newRec.flowControl = file.meta?.fcName && !file?.meta?.fcName.match(/none/i) ? file.meta.fcName : 'No FC';
+ // If an FC used => assign the flowControl field w/ the value from the Qfile
+ if (file.meta.fcType && file.meta.fcType.trim().length && !file.meta.fcType.match(/none/i))
+ newRec.flowControl = file.meta.fcType;
if (this.curPlayLoc.sprayStat) {
newRec.flowRateAp = this.curPlayLoc.lminApp; // Apply for Liquid
// if (file.meta && file.meta.appRate && (!file.meta.useFC || !this.curPlayLoc.lminApp)) {
- if (file.meta?.appRate && (!file.meta?.useFC || !this.curPlayLoc.lminApp)) {
+ if (file.meta && file.meta.appRate && (!file.meta.useFC || !this.curPlayLoc.lminApp)) {
const uniRate = UnitUtils.toRateUnit(file.meta.appRate, file.rateUnit, false);
- newRec.rateUnit = uniRate.unit; // Expected in Metrics
newRec.appRateAp = uniRate.value;
+ newRec.rateUnit = uniRate.unit; // Expected in Metrics
} else {
if (this.playMatType === MatType.LIQUID) {
- newRec.rateUnit = RateUnit.LPH;
newRec.appRateAp = UnitUtils.appRateFromFlowRate(newRec.flowRateAp, this.curPlayLoc.swath, newRec.speed);
+ newRec.rateUnit = RateUnit.LPH;
}
else {
- newRec.rateUnit = RateUnit.KGPH;
newRec.appRateAp = this.curPlayLoc.lminApp;
+ newRec.rateUnit = RateUnit.KGPH;
}
}
this.lastPlayUnit = newRec.rateUnit;
@@ -2791,36 +2771,25 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
newRec.bmPressure = this.curPlayLoc.psi || 0.0;
- if (file.meta?.sprCoverage && file.meta.sprCoverage.length === 3)
+ if (file.meta.sprCoverage && file.meta.sprCoverage.length === 3)
newRec.area = file.meta.sprCoverage[1]; // Current spray zone area size in metric, ha
newRec.swathWidth = this.curPlayLoc.swath;
- newRec.sprOnLag = file.meta?.sprOnLag || 0;
- newRec.sprOffLag = file.meta?.sprOffLag || 0;
- newRec.pulsesPLiter = file.meta?.pulsesPerLit;
+ newRec.sprOnLag = file.meta.sprOnLag || 0;
+ newRec.sprOffLag = file.meta.sprOffLag || 0;
+ newRec.pulsesPLiter = file.meta.pulsesPerLit;
// For Output 3
- newRec.areaName = file.meta?.areaOrZone;
+ newRec.areaName = file.meta.areaOrZone;
newRec.totLnLength = this.totLnLength; // Skipped, not necessary. It requires reading all gridlines from files
- const matType = this.playingFile?.file?.meta?.matType;
- // Planned/Target Application Rate
- if (this.isPlayingAgNavFile) {
- if ((file.meta && !isNaN(file.meta?.appRate) && file.meta?.appRate !== 0)) {
- newRec.applicRate = file.meta.appRate;
- newRec.applicRateUnit = file.rateUnit;
- } else {
- newRec.applicRateUnit = this.job.appRateUnit;
- newRec.applicRate = this.job.appRate;
- }
- }
- else if (!isNaN(this.curPlayLoc?.lhaReq) && matType) {
- newRec.applicRateUnit = matType === MatType2.WET ? RateUnit.LPH : RateUnit.KGPH;
- newRec.applicRate = this.curPlayLoc.lhaReq;
- }
- else {
- newRec.applicRateUnit = this.job.appRateUnit;
+ // Planned/Target Application Rate
+ if ((file.meta && file.meta.appRate !== 0)) {
+ newRec.applicRate = file.meta.appRate;
+ newRec.applicRateUnit = file.rateUnit;
+ } else {
newRec.applicRate = this.job.appRate;
+ newRec.applicRateUnit = this.job.appRateUnit;
}
//
@@ -2828,8 +2797,8 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
const sprTot = UnitUtils.toArea(this.areaSprTot.total, this.isUS);
// (AreaSprTot - Mapped Area)/Mapped Area * 100. If value is negative, it indicates undersprayed or area not finished
- newRec.overSprayed = newRec.mappedArea ? ((sprTot - newRec.mappedArea) / newRec.mappedArea) * 100 : 0;
- newRec.pilotName = file.meta?.operator;
+ newRec.overSprayed = ((sprTot - newRec.mappedArea) / newRec.mappedArea) * 100;
+ newRec.pilotName = file.meta.operator;
if (!newRec.pilotName && this.job.operator)
newRec.pilotName = this.job.operator.name;
@@ -2936,21 +2905,14 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
if (cb) cb();
};
const fid = this.selDataFiles[nextFileIdx].fid;
-
- // Initialize pagination tracking if not exists
- if (!this.fileDataPagination.has(fid)) {
- this.fileDataPagination.set(fid, {
- hasMore: true,
- startingAfter: null,
- loading: false,
- allLoaded: false
+ if (!this.filesDataSet[fid].loaded) {
+ this.jobSvc.getFilesData([fid]).subscribe(filesdata => {
+ if (filesdata.length) {
+ this.filesDataSet[fid].data = filesdata[0].data;
+ this.filesDataSet[fid].loaded = true;
+ }
+ setNextFile(nextFileIdx, cb);
});
- }
-
- const pagination = this.fileDataPagination.get(fid);
-
- if (!this.filesDataSet[fid].loaded || !pagination.allLoaded) {
- this.loadFileDataWithPagination(fid, () => setNextFile(nextFileIdx, cb));
} else {
setNextFile(nextFileIdx, cb);
}
@@ -2960,80 +2922,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
}
}
- private async loadFileDataWithPagination(fileId: string, callback?: Function) {
- const pagination = this.fileDataPagination.get(fileId);
- if (pagination.loading) return;
-
- // Initialize file data array if not exists
- if (!this.filesDataSet[fileId]) {
- this.filesDataSet[fileId] = { data: [], loaded: false };
- }
-
- pagination.loading = true;
- let hasMore = true;
-
- try {
- while (hasMore) {
- const params: any = {};
-
- if (pagination.startingAfter) {
- params.startingAfter = pagination.startingAfter;
- }
- // console.log(`Loading page for ${fileId}, cursor: ${pagination.startingAfter}`);
- const result = await this.jobSvc.getFilesData(fileId, params).toPromise();
-
- // console.log(`Response for ${fileId}:`, {
- // dataLength: result?.data?.length,
- // hasMore: result?.hasMore,
- // startingAfter: result?.startingAfter
- // });
- if (result && result.data && result.data.length > 0) {
- // Append data to existing array
- this.filesDataSet[fileId].data.push(...result.data);
-
- // Check if there's more data
- hasMore = result.hasMore === true;
-
- if (hasMore && result.startingAfter) {
- // Update cursor for next page
- pagination.startingAfter = result.startingAfter;
- } else {
- hasMore = false;
- }
- } else {
- // No more data or empty response
- hasMore = false;
- }
- }
-
- // All data loaded
- pagination.allLoaded = true;
- pagination.loading = false;
- pagination.hasMore = false;
- this.filesDataSet[fileId].loaded = true;
- if (callback) callback();
-
- } catch (error) {
- pagination.loading = false;
- console.error('Error loading file data:', error);
- if (callback) callback();
- }
- }
-
- private resetFilePagination(fileId: string) {
- if (this.filesDataSet[fileId]) {
- this.filesDataSet[fileId].data = [];
- this.filesDataSet[fileId].loaded = false;
- }
-
- this.fileDataPagination.set(fileId, {
- hasMore: true,
- startingAfter: null,
- loading: false,
- allLoaded: false
- });
- }
-
private createLine(locs = [], isSpray: boolean) {
locs = locs || [];
let line, ops;
@@ -3080,8 +2968,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
const utmP = UTM.fromLatLng(_latlng, this.refZ.zone);
if (NumUtils.isNumber(this.curPlayRec.driftX) && NumUtils.isNumber(this.curPlayRec.driftY) && (this.curPlayRec.driftX !== 0.0 || this.curPlayRec.driftY !== 0.0)) {
const newLL = UTM.toLatLng({ zone: utmP.zone, x: (+utmP.x + this.curPlayRec.driftX), y: (+utmP.y + this.curPlayLoc.driftY) });
- // if (NumUtils.isNumber(this.curPlayRec.depositX) && NumUtils.isNumber(this.curPlayRec.depositY) && (this.curPlayRec.depositX !== 0.0 || this.curPlayRec.depositY !== 0.0)) {
- // const newLL = UTM.toLatLng({ zone: utmP.zone, x: (+utmP.x + this.curPlayRec.depositX), y: (+utmP.y + this.curPlayLoc.depositY) });
if (newLL) {
_latlng.lat = newLL.lat;
_latlng.lng = newLL.lng;
@@ -3105,12 +2991,12 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
/* Rules: If the deposit location is NOT inside any XCLs, and if drift position is on an XCL, don’t plot or paint spray on*/
const depLL = UTM.toLatLng({ zone: this.refZ.zone, x: (+orgUtmPoint.x + this.curPlayRec.depositX), y: (+orgUtmPoint.y + this.curPlayRec.depositY) });
// if (this.isDebug) {
- // L.circle([llPoint.lat, llPoint.lng], 4, { color: 'yellow' }).addTo(this.map);
- // L.circle([depLL.lat, depLL.lng], 7, { color: 'white' }).addTo(this.map)
+ // L.circle([llPoint.lat, llPoint.lng], 4, { color: 'yellow' }).addTo(this.map);
+ // L.circle([depLL.lat, depLL.lng], 7, { color: 'white' }).addTo(this.map)
// };
if (!this.isPointinPolys(depLL.lat, depLL.lng, this.job.excludedAreas)) {
// this.isDebug && L.circle([depLL.lat, depLL.lng], 10, { color: 'red' }).addTo(this.map);
- return false;
+ return null;
}
}
@@ -3299,7 +3185,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this._player = new PlayBack(this.atRecord.bind(this));
this.player.speed = this.playSpd;
- if ((this.job.appRateUnit == RateUnit.LBPA || this.job.appRateUnit == RateUnit.KGPH))
+ if ((this.job.appRateUnit == 2 || this.job.appRateUnit == 4))
this.playMatType = MatType.DRY;
this.playIdx = -1;
this.totLnLength = 0;
@@ -3316,7 +3202,6 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte
this.player.speed = e.value;
});
}
-
onTzChange(e) {
if (!e) return;
if (this.curPlayRec) {
diff --git a/Development/client/src/app/job/job.module.ts b/Development/client/src/app/job/job.module.ts
index b2ded9a..15d7698 100644
--- a/Development/client/src/app/job/job.module.ts
+++ b/Development/client/src/app/job/job.module.ts
@@ -33,10 +33,9 @@ import { JobMgtComponent } from './job-mgt.component';
import { AppSharedModule } from '../shared/app-shared.module';
import { JobListComponent } from './job-list/job-list.component';
import { JobEditComponent } from './job-edit/job-edit.component';
-import { JobAssignmentComponent } from './job-assignment/job-assignment.component';
import { JobMapEditComponent } from './job-map-edit/job-map-edit.component';
import { JobsRoutingModule } from './job-routing.module';
-import { InvoicesModule } from '@app/invoices/invoices.module';
+import {InvoicesModule} from '@app/invoices/invoices.module';
@NgModule({
imports: [
@@ -51,7 +50,7 @@ import { InvoicesModule } from '@app/invoices/invoices.module';
StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer),
EffectsModule.forFeature([JobEffects]), InvoicesModule,
],
- declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent],
+ declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobMapEditComponent],
providers: [DatePipe],
schemas: [
CUSTOM_ELEMENTS_SCHEMA
diff --git a/Development/client/src/app/job/reducers/index.ts b/Development/client/src/app/job/reducers/index.ts
index 56b6ba4..f8fcf86 100644
--- a/Development/client/src/app/job/reducers/index.ts
+++ b/Development/client/src/app/job/reducers/index.ts
@@ -10,60 +10,26 @@ import { IUIJob } from '../models/job.model';
export const getJobsState = createFeatureSelector(fromJobs.FEATURE_KEY);
-// Safe wrapper that handles undefined state during lazy module loading
-// This prevents "Cannot read properties of undefined (reading 'ids')" error
-export const getJobsStateOrInitial = createSelector(
- getJobsState,
- (state) => {
- if (!state) {
- return {
- ids: [],
- entities: {},
- loading: false,
- loaded: false,
- selectedId: null
- } as fromJobs.State;
- }
- return state;
- }
-);
-
export const getSelectedJobId = createSelector(
- getJobsStateOrInitial,
+ getJobsState,
fromJobs.getSelectedId
);
export const getIsLoading = createSelector(
- getJobsStateOrInitial,
+ getJobsState,
fromJobs.getIsLoading
);
export const getIsLoaded = createSelector(
- getJobsStateOrInitial,
+ getJobsState,
fromJobs.getIsLoaded
);
-// Use safe wrapper with adapter selectors to prevent undefined access
-const entitySelectors = fromJobs.adapter.getSelectors(getJobsStateOrInitial);
-
-export const getJobsIds = createSelector(
- entitySelectors.selectIds,
- (ids) => ids || []
-);
-
-export const getJobEntities = createSelector(
- entitySelectors.selectEntities,
- (entities) => entities || {}
-);
-
-export const getAllJobs = createSelector(
- entitySelectors.selectAll,
- (jobs) => jobs || []
-);
-
-export const getTotalJobs = createSelector(
- entitySelectors.selectTotal,
- (total) => total || 0
-);
+export const {
+ selectIds: getJobsIds,
+ selectEntities: getJobEntities,
+ selectAll: getAllJobs,
+ selectTotal: getTotalJobs,
+} = fromJobs.adapter.getSelectors(getJobsState);
export const getSelectedJob = createSelector(
getJobEntities,
diff --git a/Development/client/src/app/pages/app.password-reset.component.html b/Development/client/src/app/pages/app.password-reset.component.html
index c013af8..f321b54 100644
--- a/Development/client/src/app/pages/app.password-reset.component.html
+++ b/Development/client/src/app/pages/app.password-reset.component.html
@@ -6,30 +6,26 @@
Reset your password
-
-
Enter your login Username (email) and we will send you a password reset link
- Invalid Email
+ Invalid Email
-
+
-
-
- If the email address you entered is valid, we have sent you a password reset email.
-
- Check your email for a link to reset your password. It it doesn't appear within a few minutes, check your spam folder.
+ Check your email for a link to reset your password. It it doesn't appear within a few minutes, check your spam folder.
+
+
+
-
-
-
-
-
- {{errorMessage}}
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Development/client/src/app/pages/app.password-reset.component.ts b/Development/client/src/app/pages/app.password-reset.component.ts
index d9d2d41..585678b 100644
--- a/Development/client/src/app/pages/app.password-reset.component.ts
+++ b/Development/client/src/app/pages/app.password-reset.component.ts
@@ -1,14 +1,13 @@
import { Component, OnInit, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
+import { environment } from '@environments/environment';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
import { BaseComp } from '../shared/base/base.component';
-import { GC, globals } from '@app/shared/global';
-import { GAService } from '@app/shared/ga.service';
-import { AuthService } from '@app/domain/services/auth.service';
+import { GC } from '@app/shared/global';
-export enum MODE { NONE, MAILED, CONFIRMED, ERROR };
+export enum MODE { NONE, MAILED, CONFIRMED };
@Component({
selector: 'agm-app.password-reset',
@@ -25,16 +24,12 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI
confirmedInfo: any;
password: string;
confirmPassword: string;
- errorMessage: string;
@ViewChild('f') form: HTMLFormElement;
@ViewChild('pwdInput') pwdInput: ElementRef;
@ViewChild('mailInput') mailInput: ElementRef;
- constructor(
- private readonly route: ActivatedRoute,
- private readonly gaService: GAService
- ) {
+ constructor(private readonly route: ActivatedRoute) {
super();
this["name"] = "AppPasswordResetComp";
}
@@ -44,7 +39,7 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI
switchMap(
(params: ParamMap) => {
if (params.get('id') && params.get('token'))
- return this.authSvc.validateResetPassword({ id: params.get('id'), token: params.get('token') });
+ return this.authSvc.resetPassword({ id: params.get('id'), token: params.get('token') });
else
return of(undefined);
}
@@ -83,33 +78,9 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI
this.authSvc.mailPwdReset({ email: this.userEmail }).subscribe(
(res) => {
- // Track password reset request
- this.gaService.trackPasswordResetRequested({
- request_method: 'forgot_password_page',
- user_exists: true, // Assuming user exists if request was successful
- platform: 'web'
- });
-
this.mode = MODE.MAILED;
},
- (err) => {
- // Check on the error code
- if (err.status < 500) {
- // Track password reset request (even if user doesn't exist)
- this.gaService.trackPasswordResetRequested({
- request_method: 'forgot_password_page',
- user_exists: false,
- platform: 'web'
- });
-
- this.mode = MODE.MAILED;
- // this.reset();
- } else {
- // Show error message for server errors using global error handler message
- this.mode = MODE.ERROR;
- this.errorMessage = globals.apiErrorMsg(err);
- }
- }
+ () => this.reset()
);
}
@@ -124,26 +95,9 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI
this.authSvc.changePassword({ ...this.confirmedInfo, password: this.password })
.subscribe(
data => {
- // Track successful password reset
- this.gaService.trackPasswordResetCompleted({
- success: true,
- reset_token_age_minutes: 0, // Token age info not available
- platform: 'web'
- });
-
this.toLogin(true);
},
- error => {
- // Track password reset failure
- this.gaService.trackPasswordResetCompleted({
- success: false,
- reset_token_age_minutes: 0, // Token age info not available
- failure_reason: 'other',
- platform: 'web'
- });
-
- this.reset()
- }
+ () => this.reset()
);
}
@@ -154,12 +108,11 @@ export class AppPasswordResetComp extends BaseComp implements OnInit, AfterViewI
}
}
- reset() {
+ private reset() {
this.mode = MODE.NONE;
this.confirmedInfo = null;
this.password = "";
this.confirmPassword = "";
- this.errorMessage = "";
this.setFocus();
}
diff --git a/Development/client/src/app/partner-customers/models/partner-customer.model.ts b/Development/client/src/app/partner-customers/models/partner-customer.model.ts
deleted file mode 100644
index f930ed6..0000000
--- a/Development/client/src/app/partner-customers/models/partner-customer.model.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-// Interface for package information from subscription data
-export interface PackageInfo {
- packageName: string;
- status: string;
- startDate: Date;
- endDate: Date;
- recurring: boolean;
-}
-
-// Interface that matches the backend API response
-export interface PartnerCustomerApiResponse {
- _id: string;
- name: string;
- email: string;
- username: string;
- contact?: string;
- active: boolean;
- country?: string;
- createdAt: Date;
- updatedAt: Date;
- packageInfo: PackageInfo[];
-}
-
-// Frontend model for display (transformed from API response)
-export interface PartnerCustomer {
- _id?: string;
- name: string;
- contactName: string;
- phone: string;
- email: string;
- package: string; // Customer's service package type
- partnerId?: string; // Reference to the partner account
- createdAt?: Date;
- updatedAt?: Date;
- active?: boolean;
- username?: string;
- country?: string;
-}
-
-// Transform function to convert API response to display model
-export function transformPartnerCustomer(apiResponse: PartnerCustomerApiResponse): PartnerCustomer {
- // Extract the primary package name from packageInfo
- const primaryPackage = apiResponse.packageInfo && apiResponse.packageInfo.length > 0
- ? apiResponse.packageInfo[0].packageName || 'No Package'
- : 'No Package';
-
- return {
- _id: apiResponse._id,
- name: apiResponse.name,
- contactName: apiResponse.contact || apiResponse.name, // Use contact or fallback to name
- phone: '', // Not provided by backend - could be added later
- email: apiResponse.email,
- package: primaryPackage,
- createdAt: apiResponse.createdAt,
- updatedAt: apiResponse.updatedAt,
- active: apiResponse.active,
- username: apiResponse.username,
- country: apiResponse.country
- };
-}
-
-export function createNewPartnerCustomer(partnerId: string): PartnerCustomer {
- return {
- name: '',
- contactName: '',
- phone: '',
- email: '',
- package: '',
- partnerId: partnerId
- };
-}
diff --git a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.css b/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.css
deleted file mode 100644
index a85615d..0000000
--- a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.css
+++ /dev/null
@@ -1 +0,0 @@
-/* Partner Customer List Component Styles */
\ No newline at end of file
diff --git a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.html b/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.html
deleted file mode 100644
index 7587112..0000000
--- a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.html
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
- Partner Customers
-
-
-
-
-
-
-
-
-
-
- {{col.header}}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{col.header}}
-
- {{getPackageName(customer.package)}}
-
- {{customer[col.field]}}
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.ts b/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.ts
deleted file mode 100644
index 7abf755..0000000
--- a/Development/client/src/app/partner-customers/partner-customer-list/partner-customer-list.component.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
-import { Table } from 'primeng/table';
-
-import { PartnerCustomer } from '../models/partner-customer.model';
-import { PartnerCustomerService } from '../services/partner-customer.service';
-import { globals, Labels } from '@app/shared/global';
-import { BaseComp } from '@app/shared/base/base.component';
-import { AppMessageService } from '@app/shared/app-message.service';
-import { getPackageDisplayName } from '@app/profile/common';
-
-@Component({
- selector: 'agm-partner-customer-list',
- templateUrl: './partner-customer-list.component.html',
- styleUrls: ['./partner-customer-list.component.css']
-})
-export class PartnerCustomerListComponent extends BaseComp implements OnInit, OnDestroy {
- partnerCustomers: PartnerCustomer[] = [];
- loading = false;
-
- cols: any[];
-
- // Translation constants for template access
- readonly searchPlaceholder = Labels.SEARCH_PLACEHOLDER;
-
- @ViewChild('dt') dt: Table;
-
- constructor(
- private readonly partnerCustSvc: PartnerCustomerService,
- protected readonly msgSvc: AppMessageService
- ) {
- super();
-
- this.cols = [
- { field: 'name', header: globals.name, filtered: true, filterMatchMode: 'contains', width: '23%' },
- { field: 'contactName', header: $localize`:Contact name column header@@contactName:Contact Name`, filtered: true, filterMatchMode: 'contains', width: '23%' },
- { field: 'phone', header: globals.phone, filtered: true, filterMatchMode: 'contains', width: '16%' },
- { field: 'email', header: globals.email, filtered: true, filterMatchMode: 'contains', width: '18%' },
- { field: 'package', header: globals.package, filtered: true, filterMatchMode: 'contains', width: '20%' }
- ];
- }
-
- ngOnInit() {
- this.loadPartnerCustomers();
- }
-
- loadPartnerCustomers() {
- this.loading = true;
-
- this.partnerCustSvc.getPartnerCustomers().subscribe({
- next: (customers) => {
- this.partnerCustomers = customers;
- this.loading = false;
- },
- error: (error) => {
- this.msgSvc.addFailedMsg(Labels.ERROR_LOADING_PARTNER_CUSTOMERS);
- this.loading = false;
- }
- });
- }
-
- reloadCustomers() {
- this.loadPartnerCustomers();
- }
-
- /**
- * Get display name for package lookup key
- * @param lookupKey - Package lookup key (e.g., 'ess_1', 'ent_2')
- * @returns Descriptive name (e.g., 'Essential 1', 'Enterprise 2')
- */
- getPackageName(lookupKey: string): string {
- return getPackageDisplayName(lookupKey);
- }
-
- ngOnDestroy() {
- super.ngOnDestroy();
- }
-}
diff --git a/Development/client/src/app/partner-customers/partner-customer-mgt.component.ts b/Development/client/src/app/partner-customers/partner-customer-mgt.component.ts
deleted file mode 100644
index 372a924..0000000
--- a/Development/client/src/app/partner-customers/partner-customer-mgt.component.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { Component } from '@angular/core';
-
-// A child routing component for Partner Customer Feature
-@Component({
- template: `
-
- `
-})
-export class PartnerCustomerMgtComponent {
- constructor() { }
-}
diff --git a/Development/client/src/app/partner-customers/partner-customers-routing.module.ts b/Development/client/src/app/partner-customers/partner-customers-routing.module.ts
deleted file mode 100644
index 386e110..0000000
--- a/Development/client/src/app/partner-customers/partner-customers-routing.module.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { NgModule } from '@angular/core';
-import { Routes, RouterModule } from '@angular/router';
-
-import { PartnerCustomerListComponent } from './partner-customer-list/partner-customer-list.component';
-import { PartnerCustomerMgtComponent } from './partner-customer-mgt.component';
-import { AuthGuard } from '../domain/guards/auth.guard';
-import { RoleIds } from '../shared/global';
-
-const routes: Routes = [
- {
- path: '',
- component: PartnerCustomerMgtComponent,
- data: {
- roles: [RoleIds.VENDOR],
- },
- canActivate: [],
- children: [
- {
- path: '',
- component: PartnerCustomerListComponent
- }
- ]
- }
-];
-
-@NgModule({
- imports: [RouterModule.forChild(routes)],
- exports: [RouterModule]
-})
-export class PartnerCustomersRoutingModule { }
diff --git a/Development/client/src/app/partner-customers/partner-customers.module.ts b/Development/client/src/app/partner-customers/partner-customers.module.ts
deleted file mode 100644
index cdf38ea..0000000
--- a/Development/client/src/app/partner-customers/partner-customers.module.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { NgModule } from '@angular/core';
-import { CommonModule } from '@angular/common';
-
-// PrimeNG imports
-import { TableModule } from 'primeng/table';
-import { InputTextModule } from 'primeng/inputtext';
-import { ProgressSpinnerModule } from 'primeng/progressspinner';
-import { TooltipModule } from 'primeng/tooltip';
-import { ButtonModule } from 'primeng/button';
-
-import { PartnerCustomersRoutingModule } from './partner-customers-routing.module';
-import { PartnerCustomerListComponent } from './partner-customer-list/partner-customer-list.component';
-import { PartnerCustomerMgtComponent } from './partner-customer-mgt.component';
-
-
-@NgModule({
- declarations: [PartnerCustomerListComponent, PartnerCustomerMgtComponent],
- imports: [
- CommonModule,
- PartnerCustomersRoutingModule,
- // PrimeNG modules
- TableModule,
- InputTextModule,
- ProgressSpinnerModule,
- TooltipModule,
- ButtonModule
- ]
-})
-export class PartnerCustomersModule { }
diff --git a/Development/client/src/app/partner-customers/services/partner-customer.service.ts b/Development/client/src/app/partner-customers/services/partner-customer.service.ts
deleted file mode 100644
index 9796bdd..0000000
--- a/Development/client/src/app/partner-customers/services/partner-customer.service.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Injectable } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
-import { Observable } from 'rxjs';
-import { map } from 'rxjs/operators';
-
-import { PartnerCustomer, PartnerCustomerApiResponse, transformPartnerCustomer } from '../models/partner-customer.model';
-import { AuthService } from '../../domain/services/auth.service';
-
-@Injectable({
- providedIn: 'root'
-})
-export class PartnerCustomerService {
- private readonly apiUrl = '/partners';
-
- constructor(
- private readonly http: HttpClient,
- private readonly authSvc: AuthService
- ) { }
-
- /**
- * Get partner customers for a specific partner
- * @param partnerId Optional partner ID. If not provided, defaults to current authenticated user's ID
- * @returns Observable array of partner customers
- */
- getPartnerCustomers(partnerId?: string): Observable {
- const targetPartnerId = partnerId || this.authSvc.user._id;
- return this.http.get(`${this.apiUrl}/customers`, {
- params: { partnerId: targetPartnerId }
- }).pipe(
- map(apiResponse => apiResponse.map(customer => transformPartnerCustomer(customer)))
- );
- }
-
-}
diff --git a/Development/client/src/app/partners/actions/partner.actions.ts b/Development/client/src/app/partners/actions/partner.actions.ts
deleted file mode 100644
index b7be6a8..0000000
--- a/Development/client/src/app/partners/actions/partner.actions.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { createAction, props } from '@ngrx/store';
-import { Partner } from '../models/partner.model';
-
-// Load Actions
-export const loadPartners = createAction('[Partner] Load Partners');
-export const loadPartnersSuccess = createAction(
- '[Partner] Load Partners Success',
- props<{ partners: Partner[] }>()
-);
-export const loadPartnersFailure = createAction(
- '[Partner] Load Partners Failure',
- props<{ error: string }>()
-);
-
-// Load Single Partner Actions
-export const loadPartner = createAction(
- '[Partner] Load Partner',
- props<{ id: string }>()
-);
-export const loadPartnerSuccess = createAction(
- '[Partner] Load Partner Success',
- props<{ partner: Partner }>()
-);
-export const loadPartnerFailure = createAction(
- '[Partner] Load Partner Failure',
- props<{ error: string }>()
-);
-
-// Create Actions
-export const createPartner = createAction(
- '[Partner] Create Partner',
- props<{ partner: Partner }>()
-);
-export const createPartnerSuccess = createAction(
- '[Partner] Create Partner Success',
- props<{ partner: Partner }>()
-);
-export const createPartnerFailure = createAction(
- '[Partner] Create Partner Failure',
- props<{ error: string }>()
-);
-
-// Update Actions
-export const updatePartner = createAction(
- '[Partner] Update Partner',
- props<{ id: string; partner: Partner }>()
-);
-export const updatePartnerSuccess = createAction(
- '[Partner] Update Partner Success',
- props<{ partner: Partner }>()
-);
-export const updatePartnerFailure = createAction(
- '[Partner] Update Partner Failure',
- props<{ error: string }>()
-);
-
-// Delete Actions
-export const deletePartner = createAction(
- '[Partner] Delete Partner',
- props<{ id: string }>()
-);
-export const deletePartnerSuccess = createAction(
- '[Partner] Delete Partner Success',
- props<{ id: string }>()
-);
-export const deletePartnerFailure = createAction(
- '[Partner] Delete Partner Failure',
- props<{ error: string }>()
-);
-
-// UI Actions
-export const selectPartner = createAction(
- '[Partner] Select Partner',
- props<{ partner: Partner | null }>()
-);
-
-export const clearPartnerError = createAction('[Partner] Clear Error');
-export const setPartnerLoading = createAction(
- '[Partner] Set Loading',
- props<{ loading: boolean }>()
-);
diff --git a/Development/client/src/app/partners/effects/partner.effects.ts b/Development/client/src/app/partners/effects/partner.effects.ts
deleted file mode 100644
index cbc73fb..0000000
--- a/Development/client/src/app/partners/effects/partner.effects.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Router } from '@angular/router';
-import { Actions, createEffect, ofType } from '@ngrx/effects';
-import { of } from 'rxjs';
-import { map, mergeMap, catchError, tap, repeat } from 'rxjs/operators';
-import { MessageService } from 'primeng/api';
-
-import { PartnerService } from '../services';
-import * as PartnerActions from '../actions/partner.actions';
-
-@Injectable()
-export class PartnerEffects {
-
- constructor(
- private actions$: Actions,
- private partnerService: PartnerService,
- private messageService: MessageService,
- private router: Router
- ) { }
-
- loadPartners$ = createEffect(() =>
- this.actions$.pipe(
- ofType(PartnerActions.loadPartners),
- tap(() => console.log('PartnerEffects: loadPartners action received')),
- mergeMap(() =>
- this.partnerService.getPartners().pipe(
- tap(partners => console.log('PartnerEffects: service returned partners:', partners)),
- map(partners => PartnerActions.loadPartnersSuccess({ partners })),
- catchError(error => {
- console.error('PartnerEffects: error loading partners:', error);
- return of(PartnerActions.loadPartnersFailure({ error: error.message }));
- })
- )
- ),
- repeat()
- )
- );
-
- loadPartner$ = createEffect(() =>
- this.actions$.pipe(
- ofType(PartnerActions.loadPartner),
- mergeMap(action =>
- this.partnerService.getPartnerById(action.id).pipe(
- map(partner => PartnerActions.loadPartnerSuccess({ partner })),
- catchError(error => of(PartnerActions.loadPartnerFailure({ error: error.message })))
- )
- ),
- repeat()
- )
- );
-
- createPartner$ = createEffect(() =>
- this.actions$.pipe(
- ofType(PartnerActions.createPartner),
- mergeMap(action =>
- this.partnerService.createPartner(action.partner).pipe(
- map(partner => PartnerActions.createPartnerSuccess({ partner })),
- catchError(error => of(PartnerActions.createPartnerFailure({ error: error.message })))
- )
- ),
- repeat()
- )
- );
-
- updatePartner$ = createEffect(() =>
- this.actions$.pipe(
- ofType(PartnerActions.updatePartner),
- mergeMap(action =>
- this.partnerService.updatePartner(action.id, action.partner).pipe(
- map(partner => PartnerActions.updatePartnerSuccess({ partner })),
- catchError(error => of(PartnerActions.updatePartnerFailure({ error: error.message })))
- )
- ),
- repeat()
- )
- );
-
- deletePartner$ = createEffect(() =>
- this.actions$.pipe(
- ofType(PartnerActions.deletePartner),
- mergeMap(action =>
- this.partnerService.deletePartner(action.id).pipe(
- map(() => PartnerActions.deletePartnerSuccess({ id: action.id })),
- catchError(error => of(PartnerActions.deletePartnerFailure({ error: error.message })))
- )
- ),
- repeat()
- )
- );
-
- // Success Effects with Toast Messages
- createPartnerSuccess$ = createEffect(() =>
- this.actions$.pipe(
- ofType(PartnerActions.createPartnerSuccess),
- tap(action => {
- this.messageService.add({
- severity: 'success',
- summary: 'Success',
- detail: `Partner "${action.partner.name}" created successfully`
- });
- // Navigate back to partner list after successful creation
- this.router.navigate(['/partners']);
- })
- ),
- { dispatch: false }
- );
-
- updatePartnerSuccess$ = createEffect(() =>
- this.actions$.pipe(
- ofType(PartnerActions.updatePartnerSuccess),
- tap(action => {
- this.messageService.add({
- severity: 'success',
- summary: 'Success',
- detail: `Partner "${action.partner.name}" updated successfully`
- });
- // Navigate back to partner list after successful update
- this.router.navigate(['/partners']);
- })
- ),
- { dispatch: false }
- );
-
- deletePartnerSuccess$ = createEffect(() =>
- this.actions$.pipe(
- ofType(PartnerActions.deletePartnerSuccess),
- tap(() => {
- this.messageService.add({
- severity: 'success',
- summary: 'Success',
- detail: 'Partner deleted successfully'
- });
- })
- ),
- { dispatch: false }
- );
-
- // Error Effects with Toast Messages
- partnerFailure$ = createEffect(() =>
- this.actions$.pipe(
- ofType(
- PartnerActions.loadPartnersFailure,
- PartnerActions.loadPartnerFailure,
- PartnerActions.createPartnerFailure,
- PartnerActions.updatePartnerFailure,
- PartnerActions.deletePartnerFailure
- ),
- tap(action => {
- this.messageService.add({
- severity: 'error',
- summary: 'Error',
- detail: action.error || 'An error occurred'
- });
- })
- ),
- { dispatch: false }
- );
-}
diff --git a/Development/client/src/app/partners/models/partner.model.ts b/Development/client/src/app/partners/models/partner.model.ts
deleted file mode 100644
index ec22d4b..0000000
--- a/Development/client/src/app/partners/models/partner.model.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { User } from '@app/accounts/models/user.model';
-import { RoleIds } from '@app/shared/global';
-
-export interface Partner extends User {
- // Partner-specific fields (extending User base fields)
- partnerCode?: string; // e.g., "SATLOC", "AGIDRONEX" - unique identifier
-
- // User base fields are inherited:
- // _id, username, password, name, email, phone, kind, active, createdAt, updatedAt, etc.
-}
-
-export function createNewPartner(): Partner {
- return {
- _id: '0', // Required from User interface
- name: '',
- partnerCode: '',
- email: '',
- phone: '',
- username: '',
- kind: RoleIds.PARTNER, // Set to PARTNER role by default
- active: true
- };
-}
-
-// Mock data for development and testing
-export const mockPartners: Partner[] = [
- {
- _id: '1',
- name: 'AgTech Solutions Inc.',
- kind: RoleIds.PARTNER,
- active: true,
- createdAt: new Date('2024-01-15'),
- updatedAt: new Date('2024-01-15')
- },
- {
- _id: '2',
- name: 'FarmData Analytics',
- kind: RoleIds.PARTNER,
- active: true,
- createdAt: new Date('2024-02-01'),
- updatedAt: new Date('2024-02-15')
- },
- {
- _id: '3',
- name: 'Crop Monitoring Systems',
- kind: RoleIds.PARTNER,
- active: false,
- createdAt: new Date('2024-01-20'),
- updatedAt: new Date('2024-03-01')
- },
- {
- _id: '4',
- name: 'Irrigation Tech Corp',
- kind: RoleIds.PARTNER,
- active: true,
- createdAt: new Date('2024-03-10'),
- updatedAt: new Date('2024-03-10')
- },
- {
- _id: '5',
- name: 'Soil Health Innovations',
- kind: RoleIds.PARTNER,
- active: true,
- createdAt: new Date('2024-02-20'),
- updatedAt: new Date('2024-03-05')
- }
-];
diff --git a/Development/client/src/app/partners/partner-edit/partner-edit.component.css b/Development/client/src/app/partners/partner-edit/partner-edit.component.css
deleted file mode 100644
index ae8a4f3..0000000
--- a/Development/client/src/app/partners/partner-edit/partner-edit.component.css
+++ /dev/null
@@ -1,19 +0,0 @@
-/* Partner Edit Component Styles */
-
-/* ============================================================================
- PARTNER CODE CONSTRAINT - INLINE ICON (Detached Mode)
- ============================================================================
- Similar to Tail Number pattern in vehicle-edit. Icon appears beside input
- field, message content renders below via *ngTemplateOutlet projection.
- ========================================================================= */
-
-.input-with-inline-constraint {
- display: flex;
- align-items: flex-start;
- gap: 6px;
-}
-
-.input-with-inline-constraint .inline-constraint {
- margin-top: -2px;
- /* Visual debugger recommendation - align with input center */
-}
\ No newline at end of file
diff --git a/Development/client/src/app/partners/partner-edit/partner-edit.component.html b/Development/client/src/app/partners/partner-edit/partner-edit.component.html
deleted file mode 100644
index 22a5134..0000000
--- a/Development/client/src/app/partners/partner-edit/partner-edit.component.html
+++ /dev/null
@@ -1,107 +0,0 @@
-
-
-
-
Partner Information
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Development/client/src/app/partners/partner-edit/partner-edit.component.ts b/Development/client/src/app/partners/partner-edit/partner-edit.component.ts
deleted file mode 100644
index 1579a57..0000000
--- a/Development/client/src/app/partners/partner-edit/partner-edit.component.ts
+++ /dev/null
@@ -1,261 +0,0 @@
-import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
-import { FormBuilder, FormGroup, Validators } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
-import { Store } from '@ngrx/store';
-import { Subject } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
-
-import { Partner, createNewPartner } from '../models/partner.model';
-import { PartnerState } from '../reducers/partner.reducer';
-import * as PartnerActions from '../actions/partner.actions';
-import { globals, RoleIds, Labels } from '@app/shared/global';
-import { BaseComp } from '@app/shared/base/base.component';
-import { ConstraintMessageComponent } from '@app/shared/constraint-message/constraint-message.component';
-import { AccountEditorComponent } from '@app/shared/account-editor/account-editor.component';
-import { PartnerCustomerService } from '@app/partner-customers/services/partner-customer.service';
-import { AuthService } from '@app/domain/services/auth.service';
-
-@Component({
- selector: 'app-partner-edit',
- templateUrl: './partner-edit.component.html',
- styleUrls: ['./partner-edit.component.css']
-})
-export class PartnerEditComponent extends BaseComp implements OnInit, OnDestroy {
- readonly globals = globals;
- readonly Labels = Labels;
-
- partnerForm: FormGroup;
-
- isEditMode = false;
- partnerId: string | null = null;
- selectedItem: Partner;
-
- // Partner customer constraint tracking
- hasPartnerCustomers = false;
- partnerCustomerCount = 0;
- checkingPartnerCustomers = false;
-
- // ViewChild for detached constraint messages
- @ViewChild('partnerCodeConstraint') partnerCodeConstraint: ConstraintMessageComponent;
- @ViewChild('accountEditor') accountEditor: AccountEditorComponent;
-
- private destroy$ = new Subject();
-
- private _partner: Partner;
- get partner(): Partner { return this._partner; }
- set partner(partner: Partner) {
- this._partner = partner;
- this.selectedItem = Object.assign({}, partner); // create a clone object to work on the editor
- this.populateForm(partner);
- }
-
- private _isNew: boolean;
- get isNew(): boolean {
- return this._isNew;
- }
-
- // Computed property for account editor constraint
- get shouldDisableActiveCheckbox(): boolean {
- return !this.isNew && this.hasPartnerCustomers;
- }
-
- // Computed property for partner code constraint
- get shouldDisablePartnerCode(): boolean {
- return !this.isNew && this.hasPartnerCustomers;
- }
-
- // Computed property for constraint message
- get activeCheckboxConstraintMessage(): string {
- if (this.shouldDisableActiveCheckbox) {
- const prefix = Labels.CANNOT_DEACTIVATE_PARTNER_PREFIX;
- const suffix = Labels.CANNOT_DEACTIVATE_PARTNER_SUFFIX;
- return `${prefix} ${this.partnerCustomerCount} ${suffix}`;
- }
- return '';
- }
-
- // Computed property for partner code constraint message
- get partnerCodeConstraintMessage(): string {
- if (this.shouldDisablePartnerCode) {
- const prefix = Labels.CANNOT_CHANGE_PARTNER_CODE_PREFIX;
- const suffix = Labels.CANNOT_CHANGE_PARTNER_CODE_SUFFIX;
- return `${prefix} ${this.partnerCustomerCount} ${suffix}`;
- }
- return '';
- }
-
- constructor(
- private fb: FormBuilder,
- private route: ActivatedRoute,
- protected router: Router,
- protected store: Store<{ partner: PartnerState }>,
- private partnerCustomerService: PartnerCustomerService,
- protected authSvc: AuthService
- ) {
- super();
- this.partnerForm = this.createForm();
- }
-
- ngOnInit(): void {
- // Get resolved partner data from route
- const resolvedPartner = this.route.snapshot.data['partner'] as Partner | null;
- this.partnerId = this.route.snapshot.paramMap.get('id');
- this.isEditMode = !!resolvedPartner;
- this._isNew = !this.isEditMode;
-
- if (resolvedPartner) {
- // Edit mode: use resolved partner data
- this.partner = resolvedPartner;
- // Check for partner customers to determine active checkbox constraints
- this.checkPartnerCustomers();
- } else {
- // New partner mode: create new partner
- const newPartner = createNewPartner();
- this.partner = newPartner;
- }
- }
-
- ngOnDestroy(): void {
- this.destroy$.next();
- this.destroy$.complete();
- }
-
- // ============================================================================
- // PARTNER CUSTOMER CONSTRAINT CHECKING
- // ============================================================================
-
- /**
- * Check if partner has associated customers that would prevent deactivation
- */
- private checkPartnerCustomers(): void {
- if (this.isNew || !this.partnerId) {
- return; // New partners don't have customers yet, or no partner ID available
- }
-
- this.checkingPartnerCustomers = true;
- this.partnerCustomerService.getPartnerCustomers(this.partnerId)
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (customers) => {
- this.partnerCustomerCount = customers?.length || 0;
- this.hasPartnerCustomers = this.partnerCustomerCount > 0;
- this.checkingPartnerCustomers = false;
-
- // Update form control states based on constraint
- this.updateFormControlStates();
- },
- error: (error) => {
- console.error('Error checking partner customers:', error);
- // On error, assume no customers to avoid blocking legitimate deactivation
- this.hasPartnerCustomers = false;
- this.partnerCustomerCount = 0;
- this.checkingPartnerCustomers = false;
- this.updateFormControlStates();
- }
- });
- }
-
- /**
- * Update form control disabled states based on partner customer constraints
- */
- private updateFormControlStates(): void {
- const partnerCodeControl = this.partnerForm.get('partnerCode');
-
- if (partnerCodeControl) {
- if (this.shouldDisablePartnerCode) {
- partnerCodeControl.disable();
- } else {
- partnerCodeControl.enable();
- }
- }
- }
-
- private createForm(): FormGroup {
- return this.fb.group({
- name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(100)]],
- partnerCode: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(20)]],
- email: ['', [Validators.email]],
- phone: [''],
- account: [{ username: '', password: '', active: true }] // Single control for account editor
- });
- }
-
- private populateForm(partner: Partner): void {
- this.partnerForm.patchValue({
- name: partner.name,
- partnerCode: partner.partnerCode || '',
- email: (partner as any).email || '',
- phone: (partner as any).phone || '',
- account: {
- username: (partner as any).username || '',
- password: partner.password || '',
- active: partner.active !== undefined ? partner.active : true
- }
- });
- }
-
- onSubmit(): void {
- this.savePartner();
- }
-
- savePartner(): void {
- if (this.partnerForm.valid) {
- // Get raw value to include disabled controls
- const formValue = this.partnerForm.getRawValue();
- const accountValue = formValue.account;
- const partner: Partner = {
- _id: this.isNew ? '0' : this.selectedItem._id,
- name: formValue.name,
- partnerCode: formValue.partnerCode,
- email: formValue.email,
- phone: formValue.phone,
- username: accountValue.username,
- password: accountValue.password,
- active: accountValue.active,
- kind: RoleIds.PARTNER, // UserTypes.PARTNER from backend constants
- parent: this.authSvc.user.parent
-
- };
-
- if (this.isEditMode && this.partnerId) {
- this.store.dispatch(PartnerActions.updatePartner({
- id: this.partnerId,
- partner
- }));
- } else {
- this.store.dispatch(PartnerActions.createPartner({ partner }));
- }
- } else {
- this.markFormGroupTouched();
- }
- }
-
- onCancel(): void {
- this.goBack();
- }
-
- goBack(): void {
- this.router.navigate(['/partners']);
- }
-
- private markFormGroupTouched(): void {
- Object.keys(this.partnerForm.controls).forEach(key => {
- const control = this.partnerForm.get(key);
- if (control) {
- control.markAsTouched();
- // For FormGroup controls (if any), mark all nested controls as touched
- if (control instanceof FormGroup) {
- Object.keys(control.controls).forEach(nestedKey => {
- control.get(nestedKey)?.markAsTouched();
- });
- }
- }
- });
- }
-
- // Form validation helpers
- isFieldInvalid(fieldName: string): boolean {
- const field = this.partnerForm.get(fieldName);
- return !!(field && field.invalid && (field.dirty || field.touched));
- }
-}
diff --git a/Development/client/src/app/partners/partner-list/partner-list.component.css b/Development/client/src/app/partners/partner-list/partner-list.component.css
deleted file mode 100644
index e69de29..0000000
diff --git a/Development/client/src/app/partners/partner-list/partner-list.component.html b/Development/client/src/app/partners/partner-list/partner-list.component.html
deleted file mode 100644
index 83cbf85..0000000
--- a/Development/client/src/app/partners/partner-list/partner-list.component.html
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
-
- {{Labels.PARTNER_LIST_TITLE}}
-
-
-
-
-
-
- {{col.header}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{col.header}}
- {{rowData[col.field] | date:'shortDate'}}
-
-
-
- {{rowData[col.field]}}
-
-
-
-
-
- {{Labels.TOTAL_PARTNERS}} {{ state.totalRecords }} {{Labels.PARTNERS_COUNT_SUFFIX}}
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Development/client/src/app/partners/partner-list/partner-list.component.ts b/Development/client/src/app/partners/partner-list/partner-list.component.ts
deleted file mode 100644
index 730c347..0000000
--- a/Development/client/src/app/partners/partner-list/partner-list.component.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
-import { Router } from '@angular/router';
-import { Store } from '@ngrx/store';
-import { Observable, Subject } from 'rxjs';
-import { SelectItem } from 'primeng/api';
-import { Table } from 'primeng/table';
-
-import { Partner } from '../models/partner.model';
-import { PartnerState } from '../reducers/partner.reducer';
-import * as PartnerActions from '../actions/partner.actions';
-import { Labels } from '../../shared/global';
-
-@Component({
- selector: 'app-partner-list',
- templateUrl: './partner-list.component.html',
- styleUrls: ['./partner-list.component.css']
-})
-export class PartnerListComponent implements OnInit, OnDestroy {
- partners$: Observable;
- loading$: Observable;
- error$: Observable;
- curPartner: Partner | null = null;
-
- @ViewChild("dt") dt: Table;
-
- private destroy$ = new Subject();
-
- statuses: SelectItem[];
- cols: any[];
-
- constructor(
- private store: Store<{ partner: PartnerState }>,
- private router: Router
- ) {
- this.partners$ = this.store.select(state => state.partner.partners);
- this.loading$ = this.store.select(state => state.partner.loading);
- this.error$ = this.store.select(state => state.partner.error);
-
- this.statuses = [
- { label: Labels.ALL_STATUS_FILTER, value: null },
- { label: Labels.ACTIVE_STATUS, value: true },
- { label: Labels.INACTIVE_STATUS, value: false }
- ];
-
- this.cols = [
- { field: "name", header: Labels.NAME_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains' },
- { field: "partnerCode", header: Labels.PARTNER_CODE_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains', width: '15%' },
- { field: "email", header: Labels.EMAIL_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains' },
- { field: "phone", header: Labels.PHONE_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains' },
- { field: "username", header: Labels.USERNAME_COLUMN_HEADER, filtered: true, filterMatchMode: 'contains' },
- { field: "active", header: Labels.ACTIVE_COLUMN_HEADER, width: '10%' },
- { field: "createdAt", header: Labels.CREATED_COLUMN_HEADER, width: '15%' }
- ];
- }
-
- ngOnInit(): void {
- this.loadPartners();
- }
-
- ngOnDestroy(): void {
- this.destroy$.next();
- this.destroy$.complete();
- }
-
- get canEdit(): boolean {
- return !!this.curPartner;
- }
-
- get Labels() {
- return Labels;
- }
-
- onRowSelect(event: any): void {
- this.curPartner = event.data;
- }
-
- onRowUnselect(event: any): void {
- this.curPartner = null;
- }
-
- loadPartners(): void {
- this.store.dispatch(PartnerActions.loadPartners());
- }
-
- createPartner(): void {
- this.router.navigate(['/partners/new']);
- }
-
- editPartner(partner: Partner): void {
- this.router.navigate(['/partners', partner._id]);
- }
-
- togglePartnerStatus(partner: Partner): void {
- const updatedPartner = { ...partner, active: !partner.active };
- if (partner._id) {
- this.store.dispatch(PartnerActions.updatePartner({
- id: partner._id,
- partner: updatedPartner
- }));
- }
- }
-
- getStatusSeverity(active: boolean): string {
- return active ? 'success' : 'danger';
- }
-
- getStatusLabel(active: boolean): string {
- return active ? Labels.ACTIVE_STATUS : Labels.INACTIVE_STATUS;
- }
-
- formatDate(date: Date | string | undefined): string {
- if (!date) return '';
- const d = new Date(date);
- return d.toLocaleDateString();
- }
-
- refresh(): void {
- this.loadPartners();
- }
-}
diff --git a/Development/client/src/app/partners/partner-mgt.component.ts b/Development/client/src/app/partners/partner-mgt.component.ts
deleted file mode 100644
index d1ea19b..0000000
--- a/Development/client/src/app/partners/partner-mgt.component.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Component } from '@angular/core';
-
-@Component({
- template: `
-
- `
-})
-export class PartnerMgtComponent { }
diff --git a/Development/client/src/app/partners/partners-routing.module.ts b/Development/client/src/app/partners/partners-routing.module.ts
deleted file mode 100644
index 885f3d9..0000000
--- a/Development/client/src/app/partners/partners-routing.module.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { NgModule } from '@angular/core';
-import { RouterModule, Routes } from '@angular/router';
-
-import { AuthGuard } from '../domain/guards/auth.guard';
-import { PartnerListComponent } from './partner-list/partner-list.component';
-import { PartnerEditComponent } from './partner-edit/partner-edit.component';
-import { PartnerMgtComponent } from './partner-mgt.component';
-import { PartnerResolver } from './resolvers/partner.resolver';
-import { RoleIds } from '../shared/global';
-
-const routes: Routes = [
- {
- path: '',
- component: PartnerMgtComponent,
- data: {
- roles: [RoleIds.ADMIN]
- },
- canActivate: [AuthGuard],
- children: [
- {
- path: '',
- component: PartnerListComponent,
- data: {
- roles: [RoleIds.ADMIN]
- }
- },
- {
- path: 'new',
- component: PartnerEditComponent,
- resolve: { partner: PartnerResolver },
- data: {
- roles: [RoleIds.ADMIN]
- }
- },
- {
- path: ':id',
- component: PartnerEditComponent,
- resolve: { partner: PartnerResolver },
- data: {
- roles: [RoleIds.ADMIN]
- }
- }
- ]
- }
-];
-
-@NgModule({
- imports: [RouterModule.forChild(routes)],
- exports: [RouterModule],
- providers: [AuthGuard]
-})
-export class PartnersRoutingModule { }
diff --git a/Development/client/src/app/partners/partners.module.ts b/Development/client/src/app/partners/partners.module.ts
deleted file mode 100644
index 96431d2..0000000
--- a/Development/client/src/app/partners/partners.module.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
-
-// PrimeNG Components
-import { DialogModule } from 'primeng/dialog';
-import { ConfirmDialogModule } from 'primeng/confirmdialog';
-import { ToastModule } from 'primeng/toast';
-import { MessagesModule } from 'primeng/messages';
-import { MessageModule } from 'primeng/message';
-import { CheckboxModule } from 'primeng/checkbox';
-import { InputSwitchModule } from 'primeng/inputswitch';
-import { ToolbarModule } from 'primeng/toolbar';
-import { TableModule } from 'primeng/table';
-import { ButtonModule } from 'primeng/button';
-import { InputTextModule } from 'primeng/inputtext';
-import { InputTextareaModule } from 'primeng/inputtextarea';
-import { DropdownModule } from 'primeng/dropdown';
-import { PanelModule } from 'primeng/panel';
-import { CardModule } from 'primeng/card';
-import { ProgressSpinnerModule } from 'primeng/progressspinner';
-import { TooltipModule } from 'primeng/tooltip';
-
-// Shared Modules
-import { AppSharedModule } from '../shared/app-shared.module';
-
-// NgRx
-import { StoreModule } from '@ngrx/store';
-import { EffectsModule } from '@ngrx/effects';
-
-// Components
-import { PartnerListComponent } from './partner-list/partner-list.component';
-import { PartnerEditComponent } from './partner-edit/partner-edit.component';
-import { PartnerMgtComponent } from './partner-mgt.component';
-
-// Routing
-import { PartnersRoutingModule } from './partners-routing.module';
-
-// Store
-import { partnerReducer } from './reducers/partner.reducer';
-import { PartnerEffects } from './effects/partner.effects';
-
-export const FEATURE_KEY = 'partner';
-
-@NgModule({
- declarations: [
- PartnerMgtComponent,
- PartnerListComponent,
- PartnerEditComponent
- ],
- imports: [
- DialogModule,
- ConfirmDialogModule,
- ToastModule,
- MessagesModule,
- MessageModule,
- CheckboxModule,
- InputSwitchModule,
- ToolbarModule,
- TableModule,
- ButtonModule,
- InputTextModule,
- InputTextareaModule,
- DropdownModule,
- PanelModule,
- CardModule,
- ProgressSpinnerModule,
- TooltipModule,
- AppSharedModule,
-
- StoreModule.forFeature(FEATURE_KEY, partnerReducer),
- EffectsModule.forFeature([PartnerEffects]),
- PartnersRoutingModule
- ],
- providers: [],
- schemas: [
- CUSTOM_ELEMENTS_SCHEMA
- ]
-})
-export class PartnersModule { }
diff --git a/Development/client/src/app/partners/reducers/partner.reducer.ts b/Development/client/src/app/partners/reducers/partner.reducer.ts
deleted file mode 100644
index dfe1bc0..0000000
--- a/Development/client/src/app/partners/reducers/partner.reducer.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { createReducer, on } from '@ngrx/store';
-import { Partner } from '../models/partner.model';
-import * as PartnerActions from '../actions/partner.actions';
-
-export interface PartnerState {
- partners: Partner[];
- selectedPartner: Partner | null;
- loading: boolean;
- error: string | null;
-}
-
-export const initialState: PartnerState = {
- partners: [],
- selectedPartner: null,
- loading: false,
- error: null
-};
-
-export const partnerReducer = createReducer(
- initialState,
-
- // Load Partners
- on(PartnerActions.loadPartners, (state) => ({
- ...state,
- loading: true,
- error: null
- })),
- on(PartnerActions.loadPartnersSuccess, (state, { partners }) => ({
- ...state,
- partners,
- loading: false,
- error: null
- })),
- on(PartnerActions.loadPartnersFailure, (state, { error }) => ({
- ...state,
- loading: false,
- error
- })),
-
- // Load Single Partner
- on(PartnerActions.loadPartner, (state) => ({
- ...state,
- loading: true,
- error: null
- })),
- on(PartnerActions.loadPartnerSuccess, (state, { partner }) => ({
- ...state,
- selectedPartner: partner,
- loading: false,
- error: null
- })),
- on(PartnerActions.loadPartnerFailure, (state, { error }) => ({
- ...state,
- loading: false,
- error
- })),
-
- // Create Partner
- on(PartnerActions.createPartner, (state) => ({
- ...state,
- loading: true,
- error: null
- })),
- on(PartnerActions.createPartnerSuccess, (state, { partner }) => ({
- ...state,
- partners: [...state.partners, partner],
- selectedPartner: partner,
- loading: false,
- error: null
- })),
- on(PartnerActions.createPartnerFailure, (state, { error }) => ({
- ...state,
- loading: false,
- error
- })),
-
- // Update Partner
- on(PartnerActions.updatePartner, (state) => ({
- ...state,
- loading: true,
- error: null
- })),
- on(PartnerActions.updatePartnerSuccess, (state, { partner }) => ({
- ...state,
- partners: state.partners.map(p => p._id === partner._id ? partner : p),
- selectedPartner: partner,
- loading: false,
- error: null
- })),
- on(PartnerActions.updatePartnerFailure, (state, { error }) => ({
- ...state,
- loading: false,
- error
- })),
-
- // Delete Partner
- on(PartnerActions.deletePartner, (state) => ({
- ...state,
- loading: true,
- error: null
- })),
- on(PartnerActions.deletePartnerSuccess, (state, { id }) => ({
- ...state,
- partners: state.partners.filter(p => p._id !== id),
- selectedPartner: state.selectedPartner?._id === id ? null : state.selectedPartner,
- loading: false,
- error: null
- })),
- on(PartnerActions.deletePartnerFailure, (state, { error }) => ({
- ...state,
- loading: false,
- error
- })),
-
- // UI Actions
- on(PartnerActions.selectPartner, (state, { partner }) => ({
- ...state,
- selectedPartner: partner
- })),
- on(PartnerActions.clearPartnerError, (state) => ({
- ...state,
- error: null
- })),
- on(PartnerActions.setPartnerLoading, (state, { loading }) => ({
- ...state,
- loading
- }))
-);
diff --git a/Development/client/src/app/partners/resolvers/partner.resolver.ts b/Development/client/src/app/partners/resolvers/partner.resolver.ts
deleted file mode 100644
index 236ebf2..0000000
--- a/Development/client/src/app/partners/resolvers/partner.resolver.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Resolve, ActivatedRouteSnapshot, Router } from '@angular/router';
-import { Observable, of } from 'rxjs';
-import { catchError } from 'rxjs/operators';
-
-import { Partner } from '../models/partner.model';
-import { PartnerService } from '../services';
-
-@Injectable({
- providedIn: 'root'
-})
-export class PartnerResolver implements Resolve {
- constructor(
- private partnerService: PartnerService,
- private router: Router
- ) { }
-
- resolve(route: ActivatedRouteSnapshot): Observable {
- const id = route.paramMap.get('id');
-
- // If no ID or 'new', return null for new partner creation
- if (!id || id === 'new') {
- return of(null);
- }
-
- // Load existing partner from API
- return this.partnerService.getPartnerById(id).pipe(
- catchError((error) => {
- console.error('Failed to load partner:', error);
- // On error, redirect back to partner list
- this.router.navigate(['/partners']);
- return of(null);
- })
- );
- }
-}
diff --git a/Development/client/src/app/partners/services/index.ts b/Development/client/src/app/partners/services/index.ts
deleted file mode 100644
index f78ecb1..0000000
--- a/Development/client/src/app/partners/services/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './partner.service';
diff --git a/Development/client/src/app/partners/services/partner.service.ts b/Development/client/src/app/partners/services/partner.service.ts
deleted file mode 100644
index 6847f46..0000000
--- a/Development/client/src/app/partners/services/partner.service.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import { Injectable } from '@angular/core';
-import { HttpClient, HttpParams } from '@angular/common/http';
-import { Observable, of, forkJoin } from 'rxjs';
-import { switchMap, map, catchError } from 'rxjs/operators';
-import {
- PartnerSystemUser,
-} from '../../accounts/models/user.model';
-import { handlePartnerErr } from '../../profile/common';
-
-// Partner Aircraft Response Interface
-export interface PartnerAircraftResponse {
- success: boolean;
- partnerId: string;
- customerId: string;
- partnerCode?: string;
- aircraft?: PartnerAircraft[];
- error?: string;
-}
-
-export interface PartnerAircraft {
- id: string;
- tailNumber?: string;
- name?: string;
- model?: string;
- type?: string;
- [key: string]: any; // Allow for partner-specific fields
-}
-
-@Injectable({ providedIn: 'root' })
-export class PartnerService {
- private readonly apiURL = '/partners';
-
- constructor(private readonly http: HttpClient) { }
-
- getPartners(): Observable {
- return this.http.get(this.apiURL);
- }
-
- createPartner(partner: any): Observable {
- return this.http.post(this.apiURL, partner);
- }
-
- getPartnerById(id: string | number): Observable {
- return this.http.get(`${this.apiURL}/${id}`);
- }
-
- updatePartner(id: string | number, partner: any): Observable {
- return this.http.put(`${this.apiURL}/${id}`, partner);
- }
-
- deletePartner(id: string | number): Observable {
- return this.http.delete(`${this.apiURL}/${id}`);
- }
-
- // NEW: Test partner system user authentication
- testPartnerAuth(customerId: string, partnerId: string, username: string, password: string): Observable {
- return this.http.post(`${this.apiURL}/systemUsers/testAuth`, {
- customerId,
- partnerId,
- username,
- password
- });
- }
-
- // NEW: Partner System User CRUD operations
- // Get system users for specific partner and customer (matches backend API)
- getSystemUsers(partnerId: string, customerId: string): Observable {
- const params = new HttpParams()
- .set('partnerId', partnerId)
- .set('customerId', customerId);
-
- return this.http.get(`${this.apiURL}/systemUsers`, { params });
- }
-
- // Get the current (first active) system user for a partner+customer pair.
- // Uses r987 endpoint: GET /api/partners/systemUsers/current
- // Always returns the active PSU — safe to use after a PSU rotation (old account disabled, new one created).
- getCurrentSystemUser(partnerId: string, customerId: string): Observable {
- const params = new HttpParams()
- .set('partnerId', partnerId)
- .set('customerId', customerId);
-
- return this.http.get(`${this.apiURL}/systemUsers/current`, { params });
- }
-
- // Gets all system users for a customer across all partners.
- // Pass knownPartners to reuse an already-loaded partners list and skip the GET /api/partners call.
- getSystemUsersForCustomer(customerId: string, knownPartners?: any[]): Observable {
- const partners$ = knownPartners ? of(knownPartners) : this.getPartners();
-
- return partners$.pipe(
- switchMap(partners => {
- if (!partners || partners.length === 0) {
- return of([]);
- }
-
- const systemUserRequests = partners.map(partner =>
- this.getSystemUsers(partner._id, customerId).pipe(
- catchError(() => of([]))
- )
- );
-
- return forkJoin(systemUserRequests).pipe(
- map((results: PartnerSystemUser[][]) => {
- const flattened: PartnerSystemUser[] = [];
- results.forEach(result => flattened.push(...result));
- return flattened;
- })
- );
- })
- );
- }
-
- // Method to get all system users across all partners and customers
- // Note: This is expensive as it requires multiple API calls
- getAllSystemUsers(): Observable {
- // This method would require getting all partners and all customers first
- // For now, return empty array as the backend doesn't support this efficiently
- console.warn('getAllSystemUsers() is not efficiently supported by current backend API');
- return of([]);
- }
-
- createSystemUser(systemUser: any): Observable {
- return this.http.post(`${this.apiURL}/systemUsers`, systemUser);
- }
-
- getSystemUserById(id: string): Observable {
- return this.http.get(`${this.apiURL}/systemUsers/${id}`);
- }
-
- updateSystemUser(id: string, systemUser: any): Observable {
- return this.http.put(`${this.apiURL}/systemUsers/${id}`, systemUser);
- }
-
- deleteSystemUser(id: string): Observable {
- return this.http.delete(`${this.apiURL}/systemUsers/${id}`);
- }
-
- // NEW: Get aircraft list from partner system
- // GET /api/partners/aircraft?partnerId=SATLOC&customerId=
- // OR GET /api/partners/aircraft?partnerId=&customerId=
- getPartnerAircraft(partnerId: string, customerId: string): Observable {
- const params = new HttpParams()
- .set('partnerId', partnerId)
- .set('customerId', customerId);
-
- return this.http.get(`${this.apiURL}/aircraft`, { params });
- }
-
- /**
- * Centralized partner authentication validation
- *
- * Retrieves system users for the partner and tests authentication with the first user's credentials.
- * This method consolidates authentication logic previously duplicated across multiple components
- * (account-edit, job-assignment, vehicle-list, vehicle-partner-integration).
- *
- * @param customerId - Customer ID to validate authentication for
- * @param partnerId - Partner ID to validate authentication for
- * @returns Promise resolving to object with isValid boolean and optional errorMessage string
- *
- * @example
- * const result = await this.partnerService.validatePartnerAuthentication(customerId, partnerId);
- * if (result.isValid) {
- * // Authentication successful
- * } else {
- * console.error(result.errorMessage);
- * }
- */
- async validatePartnerAuthentication(
- customerId: string,
- partnerId: string
- ): Promise<{ isValid: boolean; errorMessage?: string }> {
- try {
- // Step 1: Get the current active system user for this partner+customer.
- // Uses GET /api/partners/systemUsers/current which filters active:true server-side,
- // ensuring a PSU rotation (disable old, create new) is handled correctly.
- const systemUser = await this.getCurrentSystemUser(partnerId, customerId).toPromise();
-
- if (!systemUser) {
- return {
- isValid: false,
- errorMessage: 'No active system user found for this partner'
- };
- }
-
- // Step 2: Get credentials from the active system user
- if (!systemUser.username || !systemUser.password) {
- return {
- isValid: false,
- errorMessage: 'System user credentials are missing'
- };
- }
-
- // Step 3: Test authentication with partner API
- const authResult = await this.testPartnerAuth(
- customerId,
- partnerId,
- systemUser.username,
- systemUser.password
- ).toPromise();
-
- // Step 4: Check all possible success response formats
- const isAuthenticated = this.isAuthenticationSuccessful(authResult);
-
- if (isAuthenticated) {
- return { isValid: true };
- } else {
- // Use centralized error handler for consistent error messages
- const errorResult = handlePartnerErr(authResult);
- return {
- isValid: false,
- errorMessage: errorResult.message
- };
- }
-
- } catch (error) {
- console.error('Error validating partner authentication:', error);
- // Use centralized error handler for HTTP errors
- const errorResult = handlePartnerErr(error);
- return {
- isValid: false,
- errorMessage: errorResult.message
- };
- }
- }
-
- /**
- * Check if authentication result indicates success
- *
- * Centralized method to check all possible success response formats from partner authentication.
- * Server may return { ok: true }, { authSuccess: true }, or { success: true } depending on mode.
- *
- * @param authResult - Authentication result object from testPartnerAuth API
- * @returns true if authentication was successful, false otherwise
- *
- * @example
- * const result = await this.partnerService.testPartnerAuth(...).toPromise();
- * if (this.partnerService.isAuthenticationSuccessful(result)) {
- * // Handle success
- * }
- */
- isAuthenticationSuccessful(authResult: any): boolean {
- return authResult && (
- authResult.ok === true ||
- authResult.authSuccess === true ||
- authResult.success === true
- );
- }
-}
diff --git a/Development/client/src/app/profile/actions/usage.actions.ts b/Development/client/src/app/profile/actions/usage.actions.ts
index 4fa82c2..7e8a94e 100644
--- a/Development/client/src/app/profile/actions/usage.actions.ts
+++ b/Development/client/src/app/profile/actions/usage.actions.ts
@@ -12,7 +12,6 @@ export class FetchUsage implements Action {
toTS: number;
}
custId: string;
- effectiveMaxAcres?: number | null;
}) { }
}
diff --git a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.html b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.html
index 226498e..b9c9773 100644
--- a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.html
+++ b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.html
@@ -1,85 +1,71 @@
-
-
-
-
Billing Addresses
-
-
Select a billing address
-
+
+
+
+
+
Billing Addresses
- 0">
-
-
-
-
- {{error}}
-
-
-
-
-
-
+
Select a billing address
+
+
0" class="ui-g ui-g-nopad" style="justify-content: space-around;">
+
Address
+
Name
+
City, State, Zip
+
+
+
+
{{address.name}}
+
+
{{address.city}}, {{address.state}} {{address.zip}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1">
+
+
+
-
-
-
-
-
Address
-
Name
-
City, State, Zip/Postal Code
-
-
-
-
-
-
-
{{address.name}}
-
-
{{address.city}}, {{address.state}} {{address.postalCode}}
-
-
-
-
-
-
-
-
-
-
-
-
- 1">
-
-
-
+
-
-
-
- Add Billing Address
-
-
- Edit Billing Address
-
-
-
+ Edit Billing Address
-
-
+
- {{error}}
-
-
+
+
-
\ No newline at end of file
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.spec.ts b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.spec.ts
new file mode 100644
index 0000000..64da0b7
--- /dev/null
+++ b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.spec.ts
@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { BillingAddressListComponent } from './billing-address-list.component';
+
+describe('BillingAddressListComponent', () => {
+ let component: BillingAddressListComponent;
+ let fixture: ComponentFixture
;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ BillingAddressListComponent ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BillingAddressListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.ts b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.ts
index 6f84718..4dec98c 100644
--- a/Development/client/src/app/profile/billing-address-list/billing-address-list.component.ts
+++ b/Development/client/src/app/profile/billing-address-list/billing-address-list.component.ts
@@ -1,13 +1,17 @@
import { Component, OnInit } from '@angular/core';
+import { START_BILLING_INFO_SUCCESS, StartBillingInfo } from '@app/actions/subscription.actions';
import { BaseComp } from '@app/shared/base/base.component';
-import { SUB, SubTexts } from '../common';
-import { ActivatedRoute } from '@angular/router';
-import { User } from '@app/accounts/models/user.model';
-import { UserService } from '@app/domain/services/user.service';
-import { SubscriptionService } from '@app/domain/services/subscription.service';
-import { catchError } from 'rxjs/operators';
-import { of } from 'rxjs';
-import { Address } from '@app/domain/models/subscription.model';
+import { Mode, SubTexts } from '../common';
+
+interface Address {
+ id: string;
+ name: string;
+ street: string;
+ city: string;
+ state: string;
+ zip: string;
+ isDefault: boolean;
+}
@Component({
selector: 'billing-address-list',
@@ -16,156 +20,52 @@ import { Address } from '@app/domain/models/subscription.model';
})
export class BillingAddressListComponent extends BaseComp implements OnInit {
readonly SubTexts = SubTexts;
+ addresses: Address[];
selectedAddress: Address;
displayAddressDialog: boolean;
- currentAddress: Address;
- isNewAddress: boolean;
- canSubmit: boolean;
- user: User;
- error: string;
- constructor(
- private readonly route: ActivatedRoute,
- private readonly userSvc: UserService,
- private readonly subSvc: SubscriptionService
- ) {
+ constructor() {
super();
}
ngOnInit(): void {
- this.user = this.route.snapshot.data['user'];
- if (this.user && this.user.addresses && this.user.country) {
- this.selectedAddress = this.user.addresses.find(address => address.isBilling);
- }
+ this.loadAddresses();
+ }
+
+ loadAddresses() {
+ // Load addresses from a service or store
+ this.addresses = [
+ { id: '1', name: 'John Doe', street: '123 Main St', city: 'Anytown', state: 'CA', zip: '12345', isDefault: true },
+ { id: '2', name: 'Jane Smith', street: '456 Oak St', city: 'Othertown', state: 'TX', zip: '67890', isDefault: false }
+ ];
+ this.selectedAddress = this.addresses.find(address => address.isDefault);
}
edit(address: Address) {
+ const user = this.authSvc.user;
+ // this.store.dispatch(new StartBillingInfo({ applicatorId: user?._id, mode: Mode.UPDATE_BIL_ADR }));
this.displayAddressDialog = true;
- this.isNewAddress = false;
- this.currentAddress = { ...address };
+
}
add() {
+ // Implement add functionality
this.displayAddressDialog = true;
- this.isNewAddress = true;
- this.currentAddress = {
- name: this.user.name,
- line1: SubTexts.labelStreetAdr,
- line2: '',
- city: '',
- postalCode: '',
- state: '',
- country: this.user.country,
- isBilling: false
- };
}
del(address: Address) {
- this.confirmSvc.confirm({
- message: $localize`:@@addrDelConf:Are you sure you want to delete this address?`,
- accept: () => {
- this.user.addresses = this.user.addresses.filter(addr => addr !== address);
- this.userSvc.saveUser(this.user)
- .pipe(
- catchError(() => {
- this.error = $localize`:@@addrDelErr:There was an error deleting the address, Please try again later.`;
- return of(null);
- })
- )
- .subscribe((user) => {
- if (user && user._id) {
- this.user = user;
- this.selectedAddress = this.user.addresses.find(addr => addr.isBilling);
- }
- });
- }
- });
+ // Implement delete functionality
}
- changeBilAdr(address: Address) {
- if (!this.user?.addresses) return;
- this.subSvc.updateBillingAddressSequence(this.user._id, address)
- .pipe(
- catchError((error) => {
- this.error = $localize`:@@addrDelErr:There was an error updating the billing address, Please try again later.`;
- return of(null);
- })
- )
- .subscribe((result) => {
- if (result && result.user) {
- this.user = result.user;
- this.selectedAddress = this.user.addresses.find(address => address.isBilling);
- this.displayAddressDialog = false;
- }
- });
+ changeDefault(address: Address) {
+ // Implement change default functionality
+ }
+
+ isCompLoaded() {
+ return true;
}
gotoMySubs() {
- this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
- }
-
- onStripeAddressChange(evt: { isValid: boolean, address: Address, name: string }) {
- this.canSubmit = evt.isValid;
- if (evt.isValid) {
- const { postal_code, ...rest } = evt.address as any;
- this.currentAddress = { ...this.currentAddress, ...rest, postalCode: postal_code, name: evt.name };
- this.error = '';
- } else {
- this.error = $localize`:@@addrIncomplete:Please complete the address`;
- }
- }
-
- submit() {
- if (!this.currentAddress) {
- this.error = $localize`:@@addrIncomplete:Please complete the address.`;
- return;
- }
-
- const isBillingUpdate = this.currentAddress._id === this.selectedAddress?._id;
- let saveUser$;
-
- if (this.isNewAddress) {
- this.user.addresses.push(this.currentAddress);
- } else {
- const idx = this.user.addresses.findIndex(addr => addr._id === this.currentAddress._id);
- if (idx > -1) {
- this.user.addresses[idx] = this.currentAddress;
- }
- }
-
- if (isBillingUpdate) {
- saveUser$ = this.subSvc.updateBillingAddressSequence(this.user._id, this.currentAddress);
- } else {
- saveUser$ = this.userSvc.saveUser(this.user);
- }
-
- saveUser$
- .pipe(
- catchError((error) => {
- this.error = $localize`:@@addrSubmitErr:There was an error submitting the address, Please try again later.`;
- return of(null);
- })
- )
- .subscribe((result) => {
- if (result && result.user && result.user._id) {
- this.user = result.user;
- this.selectedAddress = this.user.addresses.find(address => address.isBilling);
- } else if (result && result._id) {
- this.user = result;
- this.selectedAddress = this.user.addresses.find(address => address.isBilling);
- }
- this.displayAddressDialog = false;
- });
- }
-
- closeDialog() {
- this.displayAddressDialog = false;
- this.canSubmit = false;
- this.currentAddress = null;
- this.error = '';
- }
-
- trackByAddressId(index: number, address: Address): string {
- return address._id;
+ // Implement navigation to subscriptions
}
}
diff --git a/Development/client/src/app/profile/billing-address/billing-address.component.css b/Development/client/src/app/profile/billing-address/billing-address.component.css
index 7b7a728..beb0851 100644
--- a/Development/client/src/app/profile/billing-address/billing-address.component.css
+++ b/Development/client/src/app/profile/billing-address/billing-address.component.css
@@ -1,14 +1,9 @@
.cc-form {
- display: flex;
+ display : flex;
align-items: center;
}
.error {
font-weight: bold;
color: red;
-}
-
-/* Minimum width for payment summary cards to prevent layout collapse */
-.card.in-card-pad {
- min-width: 300px;
}
\ No newline at end of file
diff --git a/Development/client/src/app/profile/billing-address/billing-address.component.spec.ts b/Development/client/src/app/profile/billing-address/billing-address.component.spec.ts
new file mode 100644
index 0000000..49aaaea
--- /dev/null
+++ b/Development/client/src/app/profile/billing-address/billing-address.component.spec.ts
@@ -0,0 +1,185 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { DebugElement } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { provideMockStore, MockStore } from '@ngrx/store/testing';
+import { Address, BillingInfo, StripeAddressEvent } from '@app/domain/models/subscription.model';
+import { SubscriptionService } from '@app/domain/services/subscription.service';
+import { BillingAddressComponent } from './billing-address.component';
+import { selectAuthUser } from '../../reducers/index';
+import { UserModel } from '@app/auth/models/user.model';
+import { getBillingInfo, getStripeLoaded, getSubIntentStatus, getSubscriptionStatus } from '../selectors/profile.selector';
+import { ActivatedRoute } from '@angular/router';
+
+describe('BillingAddressComponent', () => {
+ let component: BillingAddressComponent;
+ let fixture: ComponentFixture;
+ let debugElement: DebugElement;
+ let store : MockStore;
+ 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'
+ }]
+ },
+ name: 'bill'
+ };
+ const address: Address = {
+ "city": "Richmond",
+ "country": "CA",
+ "line1": "4070 Robson Street",
+ "line2": null,
+ "postal_code": "V6V 0A4",
+ "state": "BC"
+ }
+
+ const billingInfo: BillingInfo = {
+ applicatorId: '1234',
+ name : 'Justin',
+ address
+ }
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ FormsModule,
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ ],
+ declarations: [ BillingAddressComponent ],
+ providers: [
+ SubscriptionService,
+ provideMockStore({
+ selectors: [
+ {
+ selector: selectAuthUser,
+ value: user
+ },
+ {
+ selector: getBillingInfo,
+ value: billingInfo
+ },
+ {
+ selector: getSubIntentStatus,
+ value: null
+ },
+ {
+ selector: getSubscriptionStatus,
+ value: null
+ },
+ {
+ selector: getStripeLoaded,
+ value: false
+ }
+ ]
+ }),
+ { provide: ActivatedRoute, useValue: {snapshot: {data: {user: {
+ "_id": "63eaa8df132a9aefd03b2031",
+ "premium": 0,
+ "billable": false,
+ "active": true,
+ "lang": "en",
+ "markedDelete": false,
+ "kind": "1",
+ "parent": null,
+ "name": "Justin",
+ "address": null,
+ "phone": null,
+ "fax": null,
+ "email": null,
+ "contact": "Justin",
+ "username": "justin@customer.com",
+ "country": "CA"
+ }}}}
+ }
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ store = TestBed.inject(MockStore);
+ fixture = TestBed.createComponent(BillingAddressComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+
+ it('continue to checkout button should be enabled when billing address is in a valid state valid', () => {
+ const contBtn: HTMLButtonElement = debugElement.nativeElement.querySelector('button[label="Continue to payment"]');
+ expect(contBtn.disabled).toBeTruthy();
+ spyOn(component, 'contToCheckout');
+ const evt: StripeAddressEvent = {
+ complete: true,
+ value: {
+ name: 'Justin',
+ address: billingInfo.address
+ }
+ }
+ component.setEventState(evt);
+ fixture.detectChanges();
+ expect(contBtn.disabled).toBeFalsy();
+ contBtn.click();
+ expect(component.contToCheckout).toHaveBeenCalled();
+ });
+
+ describe('test billing address status in session', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BillingAddressComponent);
+ store.overrideSelector(getSubIntentStatus, {
+ code: 'error',
+ message: 'subscription intent error'
+ });
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should show subscription intent status', () => {
+ const statusElt : HTMLElement = debugElement.nativeElement.querySelector('#status');
+ expect(statusElt.innerHTML).toEqual('subscription intent error');
+ });
+
+ it('should have back button enabled', () => {
+ const backBtn: HTMLButtonElement = debugElement.nativeElement.querySelector('button[label="Back"]');
+ expect(backBtn.disabled).toBeFalsy();
+ });
+ });
+
+ describe('test billing address status re-login with un resolved payment', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BillingAddressComponent);
+ store.overrideSelector(getSubscriptionStatus, {
+ code: 'unpaid',
+ message: 'please resolve payment'
+ });
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should show subscription status', () => {
+ const statusElt : HTMLElement = debugElement.nativeElement.querySelector('#status');
+ expect(statusElt.innerHTML).toEqual('please resolve payment');
+ });
+
+ it('should have back button disabled', () => {
+ const backBtn: HTMLButtonElement = debugElement.nativeElement.querySelector('button[label="Back"]');
+ expect(backBtn.disabled).toBeTruthy();
+ });
+ });
+});
diff --git a/Development/client/src/app/profile/billing-address/billing-address.component.ts b/Development/client/src/app/profile/billing-address/billing-address.component.ts
index b5c818c..7561c02 100644
--- a/Development/client/src/app/profile/billing-address/billing-address.component.ts
+++ b/Development/client/src/app/profile/billing-address/billing-address.component.ts
@@ -13,6 +13,7 @@ import { ActivatedRoute } from '@angular/router';
import { User } from '@app/accounts/models/user.model';
import { SubAppErr, SUB, createSubStatus, SubTexts, SubStripe, Mode, hasVendorErr, STRIPE_BIL_ADDR_STYLE } from '../common';
import { BaseComp } from '@app/shared/base/base.component';
+import { globals } from '@app/shared/global';
import { getSubscriptionStatus } from '@app/reducers';
const NAME = 'name';
@@ -27,6 +28,7 @@ export class BillingAddressComponent extends BaseComp implements OnDestroy, Afte
readonly Mode = Mode;
address: StripeAddressElement;
+ // user$: Subscription;
sub$: Subscription;
status: Status;
subStatus: Status;
@@ -91,12 +93,12 @@ export class BillingAddressComponent extends BaseComp implements OnDestroy, Afte
if (subIntent?.package) {
this.subIntentPkg = subIntent?.package;
this.mode = this.subIntentPkg.mode;
- this.initializeBillingForm(subIntent.package);
+ this.handleBillingInfo(subIntent.package);
} else {
return this.subSvc.createBillingInfoPackage(this.profileUser._id).pipe(
map((billingInfoPackage: BillingInfoPackage) => {
this.mode = Mode.UPDATE_BIL_ADR;
- this.initializeBillingForm(billingInfoPackage);
+ this.handleBillingInfo(billingInfoPackage);
})
);
}
@@ -111,36 +113,48 @@ export class BillingAddressComponent extends BaseComp implements OnDestroy, Afte
);
}
- private initializeBillingForm(pkg: BillingInfoPackage | SubscriptionIntent) {
- const billingInfo = pkg?.billingInfo;
- const address = this.buildAddressWithDefaults(billingInfo?.address);
- this.updateFormValues(billingInfo?.name ?? this.profileUser.name, address);
- this.loadStripe(billingInfo?.address?.country);
- }
-
- private buildAddressWithDefaults(billingAddress?: any) {
- return {
+ private handleBillingInfo(pkg: BillingInfoPackage | SubscriptionIntent) {
+ let address: Address = {
line1: SubTexts.labelStreetAdr,
country: this.profileUser.country,
city: '',
state: '',
- postal_code: '',
- ...billingAddress
+ postal_code: ''
};
- }
- private updateFormValues(name: string, address: any) {
- this.form.controls[NAME].setValue(name);
+ if (pkg?.billingInfo?.address) {
+ address = { ...pkg.billingInfo.address, country: this.profileUser.country };
+ }
+
+ this.form.controls[NAME].setValue(pkg?.billingInfo?.name ?? this.profileUser.contact);
this.form.controls[ADDRESS].setValue(address);
+ this.loadStripe();
}
- private loadStripe(country: string = this.profileUser.country) {
+ private initStripe() {
+ if (!this.subSvc.stripe) {
+ this.store.dispatch(new LoadStripe());
+ return;
+ }
+
+ this.loadStripe();
+
+ this.sub$.add(this.subSvc.stripeLoadStatus$.subscribe({
+ next: loaded => {
+ if (loaded) this.loadStripe();
+ },
+ error: () => {
+ this.status = createSubStatus(SubAppErr.STRIPE_ERR);
+ }
+ }));
+ }
+
+ private loadStripe() {
try {
const elements = this.subSvc.stripe.elements({ appearance: STRIPE_BIL_ADDR_STYLE, locale: this.stripeLocale });
-
this.address = elements.create(ADDRESS, {
mode: "billing",
- allowedCountries: [country],
+ allowedCountries: [this.profileUser.country],
autocomplete: { mode: "google_maps_api", apiKey: environment.stripeGapiKey },
defaultValues: { name: this.form.controls[NAME].value, address: this.form.controls[ADDRESS].value }
});
@@ -176,7 +190,7 @@ export class BillingAddressComponent extends BaseComp implements OnDestroy, Afte
billingInfo: {
applicatorId: this.profileUser._id,
name: this.form.controls[NAME].value,
- address: { ...this.form.controls[ADDRESS].value, _id: this.subIntentPkg.billingInfo.address?._id }
+ address: this.form.controls[ADDRESS].value
},
subIntentPkg: this.subIntentPkg
})]));
@@ -199,23 +213,27 @@ export class BillingAddressComponent extends BaseComp implements OnDestroy, Afte
}
updateAddr() {
- this.subSvc.updateBillingAddressSequence(this.profileUser._id, {
- name: this.form.controls[NAME].value,
- city: this.form.controls[ADDRESS].value.city,
- line1: this.form.controls[ADDRESS].value.line1,
- line2: this.form.controls[ADDRESS].value.line2,
- postalCode: this.form.controls[ADDRESS].value.postal_code,
- state: this.form.controls[ADDRESS].value.state,
- country: this.form.controls[ADDRESS].value.country,
- _id: this.profileUser.billAddress?._id || ''
- }).pipe(
- map(() => this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES])),
- catchError((err) => {
- console.log(err);
- this.status = createSubStatus(SubAppErr.BIL_ADDR_ERR);
- return of(err);
- })
- ).subscribe();
+ try {
+ this.subSvc.updateBilAdr(this.profileUser._id, {
+ name: this.form.controls[NAME].value,
+ city: this.form.controls[ADDRESS].value.city,
+ line1: this.form.controls[ADDRESS].value.line1,
+ line2: this.form.controls[ADDRESS].value.line2,
+ postal_code: this.form.controls[ADDRESS].value.postal_code,
+ state: this.form.controls[ADDRESS].value.state,
+ country: this.form.controls[ADDRESS].value.country
+ }).pipe(
+ map(() => this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES])),
+ catchError((err) => {
+ console.log(err);
+ this.msgSvc.addFailedMsg(globals.doThingsFailed.replace('#do#', globals.save).replace('#thing#', globals.address));
+ return of(err);
+ })
+ ).subscribe();
+ } catch (err) {
+ console.log(err);
+ this.status = createSubStatus(SubAppErr.BIL_ADDR_ERR);
+ }
}
gotoMySubs() {
diff --git a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.css b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.css
index efdcb35..e69de29 100644
--- a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.css
+++ b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.css
@@ -1,33 +0,0 @@
-/* ============================================================================
- * PROMO DISPLAY STYLES (WI-13)
- * ============================================================================ */
-
-/* Promo notice text in success message */
-::ng-deep .promo-notice {
- color: #2E7D32;
- font-weight: 500;
- margin-top: 0.5em;
-}
-
-/* Line item styling for subscription details */
-.line-item {
- padding: 0.5em 0;
- border-bottom: 1px solid #e8e8e8;
-}
-
-.line-item:last-of-type {
- border-bottom: none;
-}
-
-/* Subscription details title */
-.title-one {
- font-size: 1.2em;
- font-weight: 600;
- margin-bottom: 1em;
- color: #212121;
-}
-
-/* Minimum width for payment summary cards to prevent layout collapse */
-.card.in-card-pad {
- min-width: 300px;
-}
\ No newline at end of file
diff --git a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.html b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.html
index f6b64c4..bc099fd 100644
--- a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.html
+++ b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.html
@@ -1,6 +1,6 @@
-
+
Confirmation
@@ -8,83 +8,38 @@
-
+
-
-
-
-
-
-
-
-
+
-
+
-
+
-
-
-
-
-
-
-
-
-
Credit Card Information
-
-
-
Card number:
-
**** {{ card?.last4 }}
-
-
-
Card type:
-
{{ card?.brand | uppercase }}
-
-
-
Expiration date:
-
{{card?.exp_month}}/{{card?.exp_year}}
-
+
@@ -95,9 +50,7 @@
diff --git a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.spec.ts b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.spec.ts
new file mode 100644
index 0000000..047d350
--- /dev/null
+++ b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.spec.ts
@@ -0,0 +1,162 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { DebugElement } from '@angular/core';
+import { provideMockStore } from '@ngrx/store/testing';
+import { AppSharedModule } from '@app/shared/app-shared.module';
+import { CheckoutConfirmComponent } from './checkout-confirm.component';
+import { PaymentSummaryComponent } from '@app/shared/payment-summary/payment-summary.component';
+import { Card, SubscriptionIntentPackage } from '@app/domain/models/subscription.model';
+import { getSubIntentPkg } from '../selectors/profile.selector';
+import { UserModel } from '@app/auth/models/user.model';
+import { selectAuthUser } from '@app/reducers';
+
+describe('CheckoutConfirmComponent', () => {
+ let component: CheckoutConfirmComponent;
+ let fixture: ComponentFixture;
+ let debugElement: DebugElement;
+ 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'
+ }]
+ },
+ name: 'bill'
+ };
+ const card: Card = {
+ pmId: 'pm_1234',
+ brand: 'Visa',
+ country: 'CA',
+ exp_month: 1,
+ exp_year: 24,
+ last4: '4242',
+ defaultPM: true
+ }
+ const subIntent: SubscriptionIntentPackage = {
+ applicatorId: '123',
+ custId: 'cust_1234',
+ selPkg: { priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: 995, lookupKey: 'ess-1'},
+ selAddons: [{ priceId: '1', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: 1495, lookupKey: 'addons-1', quantity: 1}],
+ upcomingInvoices: [{
+ id: '1234',
+ tax: 1,
+ subscription: 'sub_1234',
+ subtotal: 3,
+ subtotal_excluding_tax: 2,
+ total: 3,
+ total_excluding_tax: 2
+ }],
+ billingInfo: {
+ applicatorId: '123',
+ name: 'justin',
+ address: {
+ "city": "Richmond",
+ "country": "CA",
+ "line1": "4070 Robson Street",
+ "line2": null,
+ "postal_code": "V6V 0A4",
+ "state": "BC"
+ }
+ },
+ paymentMethods: [
+ {
+ id: '12345',
+ created: 12123,
+ card,
+ billing_details: {
+ applicatorId: '123',
+ name: 'justin',
+ address: {
+ "city": "Richmond",
+ "country": "CA",
+ "line1": "4070 Robson Street",
+ "line2": null,
+ "postal_code": "V6V 0A4",
+ "state": "BC",
+ },
+ }
+ }
+ ],
+ card
+ };
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ AppSharedModule
+ ],
+ declarations: [
+ CheckoutConfirmComponent,
+ PaymentSummaryComponent
+ ],
+ providers: [
+ provideMockStore({
+ selectors: [
+ {
+ selector: getSubIntentPkg,
+ value: subIntent
+ },
+ {
+ selector: selectAuthUser,
+ value: user
+ }
+ ]
+ })
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CheckoutConfirmComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should display user email', () => {
+ const elt : HTMLElement = debugElement.nativeElement
+ .querySelector('#confirm-email');
+ expect(elt.innerText).toContain('bill@customer1.com')
+ });
+ it('should display payment and credit card information', () => {
+ const headerElt: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('h3');
+ const lineItemElt: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('.line-item');
+ expect(headerElt.length).toEqual(2);
+ expect(lineItemElt.length).toEqual(8);
+
+ expect(headerElt[0].innerText).toContain('Payment Information');
+ expect(lineItemElt[0].innerText).toContain('Tax:\n$1');
+ expect(lineItemElt[1].innerText).toContain('Sub Total Excluding Tax:\n$2');
+ expect(lineItemElt[2].innerText).toContain('Sub Total:\n$3');
+ expect(lineItemElt[3].innerText).toContain('Total Excluding Tax:\n$2');
+ expect(lineItemElt[4].innerText).toContain('Total:\n$3');
+
+ expect(headerElt[1].innerText).toContain('Credit card Information');
+ expect(lineItemElt[5].innerText).toContain('Card number:\n**** 4242');
+ expect(lineItemElt[6].innerText).toContain('Card type:\nVisa');
+ expect(lineItemElt[7].innerText).toContain('Expiration date:\n1/24');
+ });
+ it('should go to services overview', () => {
+ spyOn(component, 'gotoManageServices');
+ const servicesBtn: HTMLButtonElement= debugElement.nativeElement
+ .querySelector('button[label="Services Overview"]');
+ servicesBtn.click();
+ expect(component.gotoManageServices).toHaveBeenCalled();
+ });
+});
diff --git a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.ts b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.ts
index 5924a2b..701c058 100644
--- a/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.ts
+++ b/Development/client/src/app/profile/checkout-confirm/checkout-confirm.component.ts
@@ -1,14 +1,12 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
-import { Addon, Card, Package, PaidAmount, Status, TrialItem, SubscriptionIntent } from '@app/domain/models/subscription.model';
-import { ActivePromo, ActivePromoService } from '@app/domain/services/active-promo.service';
+import { Addon, Card, Package, PaidAmount, Status, TrialItem } from '@app/domain/models/subscription.model';
import { getSubIntentPkg } from '@app/reducers';
import { of } from 'rxjs';
import { UserModel } from '@app/auth/models/user.model';
import { GotoAircraftList, UpdateSubscriptionStatus } from '@app/actions/subscription.actions'
import { switchMap, take, tap } from 'rxjs/operators';
-import { SubTexts, SubAppErr, createSubStatus, SUB, Mode, SubKeys, SUB_NAME } from '../common';
+import { SubTexts, SubAppErr, createSubStatus, SUB, Mode, SubKeys } from '../common';
import { BaseComp } from '@app/shared/base/base.component';
-import { AuthService } from '@app/domain/services/auth.service';
import { SubscriptionService } from '@app/domain/services/subscription.service';
import { getTotalVehicles } from '@app/entities/reducers';
import { ActivatedRoute } from '@angular/router';
@@ -24,7 +22,6 @@ import { VehicleService } from '@app/domain/services/vehicle.service';
export class CheckoutConfirmComponent extends BaseComp implements OnInit, OnDestroy {
readonly SubTexts = SubTexts;
readonly Mode = Mode;
- readonly SUB_NAME = SUB_NAME;
user: User;
mode: Mode;
@@ -36,30 +33,18 @@ export class CheckoutConfirmComponent extends BaseComp implements OnInit, OnDest
trialItems: TrialItem[];
displayDialog: boolean;
dialogMsg: string;
- subIntentPkg: SubscriptionIntent;
-
- // ============================================================================
- // PROMO SUPPORT PROPERTIES (WI-13)
- // ============================================================================
- activePromos: Map = new Map();
- packagePromo: ActivePromo | null = null;
- addonPromos: Map = new Map();
- hasApplicablePromos = false;
- totalPromoSavings: number = 0;
constructor(
private readonly subSvc: SubscriptionService,
private readonly route: ActivatedRoute,
private userSvc: UserService,
- private readonly activePromoSvc: ActivePromoService,
- readonly authSvc: AuthService
+ private readonly vehSvc: VehicleService
) {
super();
}
ngOnInit(): void {
this.user = this.route.snapshot.data['user'];
- this.loadActivePromos();
this.initSub$();
}
@@ -71,20 +56,12 @@ export class CheckoutConfirmComponent extends BaseComp implements OnInit, OnDest
return of(null);
}
- this.subIntentPkg = subIntentPkg;
this.mode = subIntentPkg?.mode;
this.payment = subIntentPkg?.amount;
this.card = subIntentPkg?.card;
this.selPkg = subIntentPkg?.selPkg;
this.selAddons = subIntentPkg?.selAddons;
this.trialItems = this.subSvc.createTrialItems(this.selPkg, this.selAddons);
-
- // Check for applicable promos (WI-13)
- // Note: checkApplicablePromos() will be called again in loadActivePromos()
- // when promos are loaded, and calculateTotalPromoSavings() will be called there
- this.checkApplicablePromos();
- // Also calculate savings here in case promos already loaded
- this.calculateTotalPromoSavings();
const curTrkQuantity = subIntentPkg?.selAddons?.find((addon) => addon?.lookupKey === SubKeys.TRACKING)?.quantity;
const orgTrkQuantity = subIntentPkg?.orgAddons?.find((addon) => addon?.lookupKey === SubKeys.TRACKING)?.quantity;
@@ -143,211 +120,7 @@ export class CheckoutConfirmComponent extends BaseComp implements OnInit, OnDest
reviewAC() {
this.displayDialog = false;
- // Navigate with reviewFlow query param to enable conditional navigation after update
- this.router.navigate(['entities', 'aircraft'], { queryParams: { reviewFlow: 'true' } });
- }
-
- // ============================================================================
- // PROMO SUPPORT METHODS (WI-13)
- // ============================================================================
-
- /**
- * Load active promos and build lookup maps
- * Creates Map for exact and type-only matches
- */
- private loadActivePromos(): void {
- this.activePromoSvc.getActivePromos().subscribe(promos => {
- this.activePromos = new Map();
- promos.forEach(p => {
- // Exact match by priceKey
- if (p.priceKey) {
- this.activePromos.set(p.priceKey, p);
- } else if (p.type) {
- // Type-only promo (priceKey = null in backend)
- 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);
- }
- });
-
- // Try to check promos now (will only process if selPkg/selAddons are also ready)
- this.checkApplicablePromos();
- // Calculate savings after promos are checked
- this.calculateTotalPromoSavings();
- });
- }
-
- /**
- * Check which items have applicable promos
- * CRITICAL: Only applies promos to NEW subscriptions (no existing subscription of same type)
- * NOTE: Can be called multiple times safely - will only process if both activePromos and selPkg are ready
- */
- private checkApplicablePromos(): void {
- // Guard: Need activePromos and at least one item (package OR addon) to be ready
- if (!this.activePromos || this.activePromos.size === 0) {
- return;
- }
-
- // Only return early if BOTH package AND addons are missing
- if (!this.selPkg && (!this.selAddons || this.selAddons.length === 0)) {
- return;
- }
-
- // Reset promo state
- this.packagePromo = null;
- this.addonPromos = new Map();
-
- // Check package promo
- if (this.selPkg?.lookupKey) {
- this.packagePromo = this.getPromoForLookupKey(this.selPkg.lookupKey, 'package');
- }
-
- // Check addon promos
- if (this.selAddons && this.selAddons.length > 0) {
- this.selAddons.forEach(addon => {
- if (addon.lookupKey) {
- const promo = this.getPromoForLookupKey(addon.lookupKey, 'addon');
- if (promo) {
- this.addonPromos.set(addon.lookupKey, promo);
- }
- }
- });
- }
-
- // Set flag if any promos exist
- this.hasApplicablePromos = !!this.packagePromo || this.addonPromos.size > 0;
- }
-
- /**
- * Get promo for a specific lookup key
- * Checks if item is eligible (new subscription) and returns matching promo
- * CRITICAL: Only applies promos to NEW subscriptions
- * @param lookupKey Package or addon lookup key (e.g., 'ess_3', 'addon_1')
- * @param type Item type ('package' or 'addon')
- * @returns ActivePromo if exists and item is new subscription, null otherwise
- */
- private getPromoForLookupKey(lookupKey: string, type: 'package' | 'addon'): ActivePromo | null {
- if (!lookupKey) return null;
-
- // Check if user has existing subscription of this type
- const userSubs = this.authSvc.user?.membership?.subscriptions || [];
-
- if (type === 'package') {
- // For packages: Check if user has ANY active (non-trial) package subscription
- // Trial subscriptions should still be eligible for promos
- const hasActivePackageSubscription = userSubs.some(sub =>
- sub.type === 'package' && sub.status !== 'trialing'
- );
-
- // Only show promo if user has NO active package subscriptions (new subscription or trial)
- if (hasActivePackageSubscription) {
- return null;
- }
- }
- // For addons: If addon is in checkout flow, backend already validated user doesn't have it
- // No additional check needed (backend filtering ensures this)
- // This matches checkout.component.ts logic (lines 502-507)
-
- // Priority 1: Exact match by priceKey
- const exactMatch = this.activePromos.get(lookupKey);
- if (exactMatch) return exactMatch;
-
- // Priority 2: Type-only match (priceKey = null in backend = applies to all of type)
- const typeOnlyPromo = this.activePromos.get(`${type}_all`);
- if (typeOnlyPromo) return typeOnlyPromo;
-
- return null;
- }
-
-
- /**
- * Get promo savings from Redux state (calculated in checkout component).
- * This ensures consistent pricing across checkout, checkout-review, and checkout-confirm.
- */
- get promoSavings(): number {
- return this.subIntentPkg?.promoSavings || 0;
- }
-
- /**
- * Get charge date from trial items for display in charge date banner
- * Returns formatted date string in user's locale (e.g., "December 24, 2025")
- */
- getTrialChargeDate(): string {
- if (!this.trialItems || this.trialItems.length === 0) return '';
-
- const firstItem = this.trialItems[0];
- if (!firstItem.trialEnd) return '';
-
- const chargeDate = new Date(firstItem.trialEnd * 1000);
- return chargeDate.toLocaleDateString('en-US', {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- }
-
- /**
- * Convert promo properties to Map for payment-info component
- * payment-info expects Map
- */
- getPromosMap(): Map {
- const promosMap = new Map();
-
- if (this.packagePromo && this.selPkg?.lookupKey) {
- promosMap.set(this.selPkg.lookupKey, this.packagePromo);
- }
-
- if (this.addonPromos && this.addonPromos.size > 0) {
- this.addonPromos.forEach((promo, lookupKey) => {
- promosMap.set(lookupKey, promo);
- });
- }
-
- return promosMap;
- }
-
- /**
- * Calculate total promo savings for trial items
- * Uses same logic as checkout component
- */
- private calculateTotalPromoSavings(): void {
- if (!this.trialItems || this.trialItems.length === 0) {
- this.totalPromoSavings = 0;
- return;
- }
-
- const promosMap = this.getPromosMap();
- this.totalPromoSavings = this.subSvc.calculatePromoSavings(
- this.trialItems,
- promosMap
- );
- }
-
- /**
- * Get list of applicable promos for template display
- * Returns array of {item, promo} objects
- */
- getApplicablePromos(): Array<{ item: Package | Addon, promo: ActivePromo }> {
- const promoList: Array<{ item: Package | Addon, promo: ActivePromo }> = [];
-
- // Add package promo if exists
- if (this.packagePromo && this.selPkg) {
- promoList.push({ item: this.selPkg, promo: this.packagePromo });
- }
-
- // Add addon promos if exist
- if (this.selAddons && this.selAddons.length > 0) {
- this.selAddons.forEach(addon => {
- const promo = this.addonPromos.get(addon.lookupKey);
- if (promo) {
- promoList.push({ item: addon, promo });
- }
- });
- }
-
- return promoList;
+ this.store.dispatch(new GotoAircraftList());
}
ngOnDestroy(): void {
diff --git a/Development/client/src/app/profile/checkout-review/checkout-review.component.css b/Development/client/src/app/profile/checkout-review/checkout-review.component.css
index c57651e..e69de29 100644
--- a/Development/client/src/app/profile/checkout-review/checkout-review.component.css
+++ b/Development/client/src/app/profile/checkout-review/checkout-review.component.css
@@ -1,21 +0,0 @@
-/* Promo Banner Styling */
-.promo-banner {
- background: linear-gradient(135deg, #E8F5E9 0%, #F1F8E9 100%);
- border-left: 4px solid #4CAF50;
-}
-
-.promo-title {
- color: #2E7D32;
- font-size: 1.2em;
- font-weight: 600;
- margin: 0 0 0.5em 0;
-}
-
-.promo-item {
- margin: 0.5em 0;
-}
-
-/* Minimum width for payment summary cards to prevent layout collapse */
-.card.in-card-pad {
- min-width: 300px;
-}
\ No newline at end of file
diff --git a/Development/client/src/app/profile/checkout-review/checkout-review.component.html b/Development/client/src/app/profile/checkout-review/checkout-review.component.html
index cfe78bd..d34cf05 100644
--- a/Development/client/src/app/profile/checkout-review/checkout-review.component.html
+++ b/Development/client/src/app/profile/checkout-review/checkout-review.component.html
@@ -10,31 +10,25 @@
-
+
-
+
@@ -42,16 +36,13 @@
-
+
@@ -59,16 +50,13 @@
-
+
@@ -77,16 +65,13 @@
-
+
@@ -95,31 +80,25 @@
-
+
-
+
@@ -127,16 +106,13 @@
-
+
@@ -159,19 +135,13 @@
-
+
-
@@ -186,9 +156,7 @@
@@ -197,16 +165,12 @@
-
+
-
+
\ No newline at end of file
diff --git a/Development/client/src/app/profile/checkout-review/checkout-review.component.spec.ts b/Development/client/src/app/profile/checkout-review/checkout-review.component.spec.ts
new file mode 100644
index 0000000..842ee51
--- /dev/null
+++ b/Development/client/src/app/profile/checkout-review/checkout-review.component.spec.ts
@@ -0,0 +1,183 @@
+import { DebugElement } from '@angular/core';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { AppSharedModule } from '@app/shared/app-shared.module';
+import { CheckoutReviewComponent } from './checkout-review.component';
+import { PaymentSummaryComponent } from '@app/shared/payment-summary/payment-summary.component';
+import { Address, Card, SubscriptionIntentPackage } from '@app/domain/models/subscription.model';
+import { provideMockStore } from '@ngrx/store/testing';
+import { getProfileState, getSubIntentPkg } from '../selectors/profile.selector';
+import { selectAuthUser } from '@app/reducers';
+import { UserModel } from '@app/auth/models/user.model';
+import { AuthService } from '@app/domain/services/auth.service';
+describe('CheckoutReviewComponent', () => {
+ let component: CheckoutReviewComponent;
+ let fixture: ComponentFixture;
+ let debugElement: DebugElement;
+ let authSvc: AuthService
+ 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'
+ }]
+ },
+ name: 'bill'
+ };
+ const card: Card = {
+ pmId: 'pm_123',
+ brand: 'Visa',
+ country: 'CA',
+ exp_month: 1,
+ exp_year: 24,
+ last4: '4242',
+ defaultPM: false
+ };
+ const address: Address = {
+ "city": "Richmond",
+ "country": "CA",
+ "line1": "4070 Robson Street",
+ "line2": null,
+ "postal_code": "V6V 0A4",
+ "state": "BC",
+ }
+ const subIntent: SubscriptionIntentPackage = {
+ applicatorId: '123',
+ custId: 'cust_1234',
+ selPkg: { priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: 995, lookupKey: 'ess-1'},
+ selAddons: [{ priceId: '1', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: 1495, lookupKey: 'addons-1', quantity: 1}],
+ upcomingInvoices: [{
+ id: '1234',
+ tax: 1,
+ subscription: 'sub_1234',
+ subtotal: 3,
+ subtotal_excluding_tax: 2,
+ total: 3,
+ total_excluding_tax: 2
+ }],
+ billingInfo: {
+ applicatorId: '123',
+ name: 'justin',
+ address
+ },
+ paymentMethods: [
+ {
+ id: '12345',
+ created: 12123,
+ card,
+ billing_details: {
+ applicatorId: '123',
+ name: 'justin',
+ address,
+ }
+ }
+ ],
+ card
+ };
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ HttpClientTestingModule,
+ AppSharedModule
+ ],
+ declarations: [
+ CheckoutReviewComponent,
+ PaymentSummaryComponent
+ ],
+ providers: [
+ provideMockStore({
+ selectors: [
+ {
+ selector: getSubIntentPkg,
+ value: subIntent
+ },
+ {
+ selector: getProfileState,
+ value: {
+ subscription: {},
+ subIntent: {}
+ }
+ },
+ {
+ selector: selectAuthUser,
+ value: user
+ }
+ ]
+ })
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CheckoutReviewComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+
+ it('should display payment and credit card information', () => {
+ const headerElt: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('h3');
+ const lineItemElt: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('.line-item');
+ expect(headerElt.length).toEqual(2);
+ expect(lineItemElt.length).toEqual(8);
+
+ expect(headerElt[0].innerText).toContain('Payment Information');
+ expect(lineItemElt[0].innerText).toContain('Tax:\n$1');
+ expect(lineItemElt[1].innerText).toContain('Sub Total Excluding Tax:\n$2');
+ expect(lineItemElt[2].innerText).toContain('Sub Total:\n$3');
+ expect(lineItemElt[3].innerText).toContain('Total Excluding Tax:\n$2');
+ expect(lineItemElt[4].innerText).toContain('Total:\n$3');
+
+ expect(headerElt[1].innerText).toContain('Credit card Information');
+ expect(lineItemElt[5].innerText).toContain('Card number:\n**** 4242');
+ expect(lineItemElt[6].innerText).toContain('Card type:\nVisa');
+ expect(lineItemElt[7].innerText).toContain('Expiration date:\n1/24');
+ });
+ it('should handle edit event', () => {
+ spyOn(component, 'editPackage');
+ spyOn(component, 'editCheckout');
+ const editPaymentBtn: HTMLButtonElement= debugElement.nativeElement
+ .querySelector('button[label="Edit-payment"]');
+ const editCardBtn: HTMLButtonElement= debugElement.nativeElement
+ .querySelector('button[label="Edit-card"]');
+ editPaymentBtn.click();
+ expect(component.editPackage).toHaveBeenCalled();
+ editCardBtn.click();
+ expect(component.editCheckout).toHaveBeenCalled();
+ });
+
+ describe('test incomplete invoices', () => {
+ beforeEach(() => {
+ authSvc = TestBed.inject(AuthService);
+ spyOn(authSvc, 'hasSubsWithStatus').and.returnValue(true);
+ fixture = TestBed.createComponent(CheckoutReviewComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('should hide edit payment information button when there is incompleteInvoice', () => {
+ const editPaymentBtn: HTMLButtonElement= debugElement.nativeElement
+ .querySelector('button[label="Edit-payment"]');
+ expect(editPaymentBtn).toBeFalsy();
+ });
+ });
+});
diff --git a/Development/client/src/app/profile/checkout-review/checkout-review.component.ts b/Development/client/src/app/profile/checkout-review/checkout-review.component.ts
index f724bd9..f2486ec 100644
--- a/Development/client/src/app/profile/checkout-review/checkout-review.component.ts
+++ b/Development/client/src/app/profile/checkout-review/checkout-review.component.ts
@@ -1,5 +1,5 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
-import { map, switchMap, take, filter } from 'rxjs/operators';
+import { map, switchMap, take } from 'rxjs/operators';
import { SubscriptionIntent, Status, Card, PastDue, Unpaid, Incomplete, LatestInvoice, Unresolved, PaidAmount } from '@app/domain/models/subscription.model';
import { getRefreshSubIntent, getSubIntentState } from '@app/reducers';
import { ClearSubscriptionStatus, Confirm, UpdateSubscription, GotoCheckout, PayUnpaidSubscription, RefeshSubscriptionIntent, SetSubscriptionIntentPrevStage, UpdatePastDue, UpdateIncomplete, UpdateUnpaid, GotoMyServices, Compound } from '@app/actions/subscription.actions';
@@ -7,7 +7,6 @@ import { Utils } from '@app/shared/utils';
import { SubTexts, SubAppErr, SUB, SubStripe, createSubStatus, Mode, hasVendorErr } from '../common';
import { BaseComp } from '@app/shared/base/base.component';
import { getIncomplete, getPastDue, getSubscriptionStatus, getUnpaid } from '@app/reducers';
-import { ActivePromo, ActivePromoService } from '@app/domain/services/active-promo.service';
@Component({
selector: 'checkout-review',
@@ -33,24 +32,12 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
vendorErr: boolean;
- // Processing state flag to prevent duplicate submissions during 3DS
- isProcessing: boolean = false;
-
- // Promo support
- activePromos: Map = new Map();
- packagePromo: ActivePromo | null = null;
- addonPromos: Map = new Map();
- hasApplicablePromos = false;
-
- constructor(
- private activePromoSvc: ActivePromoService
- ) {
+ constructor() {
super();
}
ngOnInit(): void {
this.initSub$();
- this.loadActivePromos();
this.hasPrevInvoices = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE);
if (this.hasPrevInvoices) {
this.refreshPrevSubIntent();
@@ -68,7 +55,7 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
}
},
error: err => {
- console.error('Subscription status error:', err);
+ console.log(err);
this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
}
});
@@ -79,24 +66,7 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
return this.store.select(getIncomplete);
}),
switchMap((incomplete) => {
- const prevIncomplete = this.incomplete;
this.incomplete = incomplete;
-
- // Reset processing flag when requiresAction detected (waiting for 3DS, no longer processing)
- if (incomplete?.requiresAction && incomplete?.invoices?.length > 0) {
- this.isProcessing = false;
- }
-
- // Auto-trigger 3DS popup when incomplete state changes with requiresAction
- // This handles the direct subscription pattern (r942) where backend returns
- // subscription with requires_action status after clicking Submit
- if (incomplete?.requiresAction &&
- incomplete?.invoices?.length > 0 &&
- incomplete !== prevIncomplete) {
- // Delay to ensure state is fully updated
- setTimeout(() => this.resolveIncomplete(), 100);
- }
-
return this.store.select(getUnpaid);
}),
map((unpaid) => {
@@ -145,7 +115,6 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
this.subIntentPkg = subIntent?.package;
this.payment = this.subIntentPkg?.amount;
this.card = this.subIntentPkg?.card;
- this.checkApplicablePromos();
})
).subscribe({
error: err => {
@@ -190,15 +159,6 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
submit() {
try {
- // Check if already processing to prevent duplicate submissions
- if (this.isProcessing) {
- console.warn('⚠️ Submit already in progress, ignoring duplicate click');
- return;
- }
-
- // Set processing flag immediately to disable button
- this.isProcessing = true;
-
this.store.dispatch(new UpdateSubscription({
stage: this.stage, card: this.subIntentPkg?.card,
pmId: this.subIntentPkg?.card?.pmId, defaultPM: this.subIntentPkg?.card?.defaultPM,
@@ -210,55 +170,29 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
} catch (err) {
console.log(err);
this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
- // Reset flag on error
- this.isProcessing = false;
}
}
resolvePastDue() {
try {
- // Check if already processing to prevent duplicate submissions
- if (this.isProcessing) {
- console.warn('⚠️ ResolvePastDue already in progress, ignoring duplicate click');
- return;
- }
-
if (Utils.isEmptyArray(this.pastDue?.invoices)) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
-
- // Set processing flag immediately
- this.isProcessing = true;
-
const openInvoices = this.pastDue.invoices?.filter((invoice) => invoice?.status === SubStripe.OPEN) || [];
this.confirmPayment(openInvoices, { type: SubStripe.PAST_DUE, numOfRetries: this.pastDue.numOfRetries, invoices: this.pastDue.invoices });
} catch (err) {
console.log(err);
this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
- // Reset flag on error
- this.isProcessing = false;
}
}
resolveIncomplete() {
try {
- // Check if already processing to prevent duplicate submissions
- if (this.isProcessing) {
- console.warn('⚠️ ResolveIncomplete already in progress, ignoring duplicate click');
- return;
- }
-
if (Utils.isEmptyArray(this.incomplete?.invoices)) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
-
- // Set processing flag immediately
- this.isProcessing = true;
-
this.store.dispatch(new UpdateIncomplete({ invoices: this.incomplete.invoices, requiresAction: false, requiresPM: false, numOfRetries: 0 }));
const openInvoices = this.incomplete.invoices?.filter((invoice) => invoice?.status === SubStripe.OPEN) || [];
this.confirmPayment(openInvoices, { type: SubStripe.INCOMPLETE, reason: this.status?.code, invoices: this.incomplete.invoices, numOfRetries: this.incomplete.numOfRetries, });
} catch (err) {
console.log(err);
this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
- // Reset flag on error
- this.isProcessing = false;
}
}
@@ -267,7 +201,7 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
if (Utils.isEmptyArray(openInvoices) || !unresolved) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
const stripePkgs = openInvoices?.map((invoice) => ({ clientSecret: invoice?.payment_intent?.client_secret, pmId: this.subIntentPkg?.card?.pmId ? this.subIntentPkg.card.pmId : invoice?.payment_intent?.last_payment_error ? invoice.payment_intent.last_payment_error.payment_method?.id : invoice?.payment_intent?.payment_method }));
const subIds = openInvoices?.map((invoice) => invoice.subscription) || [];
- this.store.dispatch(new Confirm({ custId: this.subIntentPkg?.custId, stripePkgs, subIds, unresolved, applicatorId: this.subIntentPkg?.applicatorId, stage: SUB.CHKOUT_REV }));
+ this.store.dispatch(new Confirm({ custId: this.subIntentPkg?.custId, stripePkgs, subIds, unresolved, applicatorId: this.subIntentPkg?.applicatorId }));
} catch (err) {
console.log(err);
this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
@@ -276,31 +210,16 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
resolveUnpaid() {
try {
- // Check if already processing to prevent duplicate submissions
- if (this.isProcessing) {
- console.warn('⚠️ ResolveUnpaid already in progress, ignoring duplicate click');
- return;
- }
-
if (Utils.isEmptyArray(this.unpaid?.invoices)) return this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
-
- // Set processing flag immediately
- this.isProcessing = true;
-
this.store.dispatch(new PayUnpaidSubscription({ pmId: this.subIntentPkg?.card?.pmId, invIds: this.unpaid.invoices?.map((invoice) => invoice.id) || [], unpaid: this.unpaid, card: this.subIntentPkg?.card, custId: this.subIntentPkg?.custId, applicatorId: this.subIntentPkg?.applicatorId }));
} catch (err) {
console.log(err);
this.status = createSubStatus(SubAppErr.CHKOUT_REV_ERR);
- // Reset flag on error
- this.isProcessing = false;
}
}
editCheckout() {
try {
- // Reset processing flag when going back to edit
- this.isProcessing = false;
-
const handlePastDue = () => this.store.dispatch(new UpdatePastDue({ invoices: this.pastDue?.invoices, numOfRetries: 0 }));
const handleIncomplete = () => this.store.dispatch(new UpdateIncomplete({ invoices: this.incomplete?.invoices, requiresAction: false, requiresPM: false, numOfRetries: 0 }));
const handleUnpaid = () => this.store.dispatch(new UpdateUnpaid({ invoices: this.unpaid?.invoices, numOfRetries: 0 }));
@@ -329,105 +248,10 @@ export class CheckoutReviewComponent extends BaseComp implements OnInit, OnDestr
}
isNotReady() {
- return this.status?.code === SUB.UPDATE_DEF_PM || this.isProcessing;
- }
-
- // ============================================================================
- // PROMO SUPPORT METHODS
- // ============================================================================
-
- private loadActivePromos(): void {
- this.activePromoSvc.getActivePromos().subscribe(promos => {
- this.activePromos = new Map();
- promos.forEach(p => {
- if (p.priceKey) {
- this.activePromos.set(p.priceKey, p);
- } else if (p.type) {
- 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);
- }
- });
- this.checkApplicablePromos();
- });
- }
-
- private checkApplicablePromos(): void {
- if (!this.activePromos || this.activePromos.size === 0) return;
- if (!this.subIntentPkg) return;
-
- const selPkg = this.subIntentPkg.selPkg;
- const selAddons = this.subIntentPkg.selAddons || [];
-
- if (!selPkg) return;
-
- // Check package promo
- this.packagePromo = this.getPromoForLookupKey(selPkg.lookupKey, 'package');
-
- // Check addon promos
- this.addonPromos = new Map();
- selAddons.forEach(addon => {
- const promo = this.getPromoForLookupKey(addon.lookupKey, 'addon');
- if (promo) {
- this.addonPromos.set(addon.lookupKey, promo);
- }
- });
-
- this.hasApplicablePromos = !!this.packagePromo || this.addonPromos.size > 0;
- }
-
- private getPromoForLookupKey(lookupKey: string, type: 'package' | 'addon'): ActivePromo | null {
- // Only apply promos to NEW subscriptions
- const userSubs = this.authSvc.user?.membership?.subscriptions || [];
-
- if (type === 'package') {
- const hasAnyPackageSubscription = userSubs.some(sub => sub.type === 'package');
- if (hasAnyPackageSubscription) return null; // Not a new subscription
- }
-
- // Priority 1: Exact match
- const exactMatch = this.activePromos.get(lookupKey);
- if (exactMatch) return exactMatch;
-
- // Priority 2: Type-only match
- const typeOnlyPromo = this.activePromos.get(`${type}_all`);
- return typeOnlyPromo || null;
- }
-
- getApplicablePromos(): { item: any; promo: ActivePromo }[] {
- const result: { item: any; promo: ActivePromo }[] = [];
-
- if (this.packagePromo && this.subIntentPkg?.selPkg) {
- result.push({ item: this.subIntentPkg.selPkg, promo: this.packagePromo });
- }
-
- if (this.subIntentPkg?.selAddons) {
- this.subIntentPkg.selAddons.forEach(addon => {
- const promo = this.addonPromos.get(addon.lookupKey);
- if (promo) {
- result.push({ item: addon, promo });
- }
- });
- }
-
- return result;
- }
-
- /**
- * Get promo savings from Redux state (calculated in checkout component).
- * This ensures consistent pricing across checkout and checkout-review.
- */
- get promoSavings(): number {
- return this.subIntentPkg?.promoSavings || 0;
+ return this.status?.code === SUB.UPDATE_DEF_PM;
}
ngOnDestroy(): void {
- if (this.isProcessing) {
- console.warn('⚠️ Component destroyed while processing');
- }
- this.isProcessing = false;
super.ngOnDestroy();
}
}
diff --git a/Development/client/src/app/profile/checkout/checkout.component.css b/Development/client/src/app/profile/checkout/checkout.component.css
index 3ffd467..c970519 100644
--- a/Development/client/src/app/profile/checkout/checkout.component.css
+++ b/Development/client/src/app/profile/checkout/checkout.component.css
@@ -7,31 +7,6 @@
font-weight: lighter;
}
-/* Override constraint-message max-width for trial charge banner */
-/* Global styles limit to 600px, but we need full-width to match card container below */
-/* Target the internal div with class .agm-constraint-message, not the component tag */
-:host ::ng-deep .in-card-pad>.ui-g-12 .agm-constraint-message {
- max-width: 100% !important;
-}
-
-/* Remove padding from the .ui-g-12 container wrapping the constraint message banner */
-/* Add bottom margin for consistent spacing between banner and card below (1em = 16px) */
-:host ::ng-deep .in-card-pad>.ui-g-12:not(.card) {
- padding: 0 !important;
- margin-bottom: 1em;
-}
-
-/* Trial charge date banner: calendar icon in AgMission primary green to match promo icon style */
-:host ::ng-deep .in-card-pad .agm-constraint-content .pi-calendar {
- color: #4CAF50;
-}
-
-/* Minimum width for payment summary cards to prevent Stripe element collapse */
-.card.in-card-pad {
- min-width: 300px;
-}
-
-/* Minimum width for the dual-card (charges + refund) flex container */
-.dyn-col {
- min-width: 580px;
+:host ::ng-deep .ui-radiobutton {
+ float: left;
}
diff --git a/Development/client/src/app/profile/checkout/checkout.component.html b/Development/client/src/app/profile/checkout/checkout.component.html
index a363c4b..a471dc8 100644
--- a/Development/client/src/app/profile/checkout/checkout.component.html
+++ b/Development/client/src/app/profile/checkout/checkout.component.html
@@ -1,8 +1,7 @@
-
-
+
+
Payment details
@@ -17,49 +16,26 @@
@@ -69,64 +45,11 @@
-
-
-
-
-
- {{ Labels.YOUR_TRIAL_IS_ACTIVE_UNTIL }} {{ getTrialChargeDate() }}. {{
- Labels.YOU_WILL_BE_CHARGED_ON_THAT_DATE }} {{ Labels.NO_CHARGE_WILL_BE_MADE_TODAY }}
-
-
-
-
-
-
- Your Subscription After Trial Ends
-
-
-
-
- Items
-
-
- Price
-
-
-
-
-
-
-
-
0">
-
-
- Total Promo Savings:
-
-
- -${{ formatCurrency(totalPromoSavings) }} US
-
-
-
-
+
+
-
-
-
- Total Total (Before Tax) :
-
-
- ${{ formatCurrency(amount?.total) }} US
-
-
-
-
-
- Plus Applicable Tax
-
-
-
+
@@ -135,51 +58,22 @@
-
-
-
-
0">
-
-
-
- Total Promo Savings:
-
-
- -${{ formatCurrency(totalPromoSavings) }} US
-
-
-
-
-
-
- After Trial Total After Trial Total (Before Tax) :
-
-
- ${{ formatCurrency(amount?.total) }} US
-
-
-
-
-
+
-
+
{{status?.message}}
-
+
-
-
+
+ {{type}}
Items
@@ -198,13 +92,11 @@
Payment Methods
-
+
-
@@ -212,8 +104,7 @@
@@ -221,8 +112,7 @@
{{status?.message}}
@@ -234,8 +124,7 @@
-
+
@@ -244,9 +133,7 @@
diff --git a/Development/client/src/app/profile/checkout/checkout.component.spec.ts b/Development/client/src/app/profile/checkout/checkout.component.spec.ts
new file mode 100644
index 0000000..d8646f3
--- /dev/null
+++ b/Development/client/src/app/profile/checkout/checkout.component.spec.ts
@@ -0,0 +1,215 @@
+import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { DebugElement } from '@angular/core';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { Dropdown, DropdownItem } from 'primeng/dropdown';
+import { AppSharedModule } from '@app/shared/app-shared.module';
+import { provideMockStore } from '@ngrx/store/testing';
+import { getSubIntentPkg, getSubIntentState, getSubIntentStatus } from '../selectors/profile.selector';
+import { CheckoutComponent } from './checkout.component';
+import { CreditcardFormComponent } from '@app/profile/creditcard-form/creditcard-form.component';
+import { Address, Card, SubscriptionIntentPackage } from '@app/domain/models/subscription.model';
+
+describe('CheckoutComponent', () => {
+ let component: CheckoutComponent;
+ let fixture: ComponentFixture
;
+ let debugElement: DebugElement;
+ const card: Card = {
+ pmId: 'pm_123',
+ brand: 'Visa',
+ country: 'CA',
+ exp_month: 1,
+ exp_year: 24,
+ last4: '4242',
+ defaultPM: false
+ };
+ const card2: Card = {
+ pmId: 'pm_345',
+ brand: 'Master Card',
+ country: 'CA',
+ exp_month: 1,
+ exp_year: 25,
+ last4: '2342',
+ defaultPM: false
+ };
+ const address: Address = {
+ "city": "Richmond",
+ "country": "CA",
+ "line1": "4070 Robson Street",
+ "line2": null,
+ "postal_code": "V6V 0A4",
+ "state": "BC",
+ "name": 'justin',
+ }
+ const subIntent: SubscriptionIntentPackage = {
+ applicatorId: '123',
+ custId: 'cust_1234',
+ selPkg: { priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: 995, lookupKey: 'ess-1'},
+ selAddons: [{ priceId: '1', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: 1495, lookupKey: 'addons-1', quantity: 1}],
+ upcomingInvoices: [{
+ id: '123',
+ subscription: '234',
+ tax: 1,
+ subtotal_excluding_tax: 2,
+ total: 3,
+ }],
+ billingInfo: {
+ name: 'justin',
+ applicatorId: '123',
+ address
+ },
+ paymentMethods: [
+ {
+ id: '12345',
+ created: 12123,
+ card,
+ billing_details: {
+ applicatorId: '123',
+ address,
+ name: 'justin'
+ }
+ },
+ {
+ id: '4343',
+ created: 12126,
+ card: card2,
+ billing_details: {
+ applicatorId: '123',
+ address,
+ name: 'justin'
+ }
+ }
+ ],
+ card
+ };
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ FormsModule,
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ BrowserAnimationsModule,
+ AppSharedModule
+ ],
+ declarations: [
+ CheckoutComponent,
+ CreditcardFormComponent,
+ ],
+ providers: [
+ provideMockStore({
+ selectors: [
+ {
+ selector: getSubIntentPkg,
+ value: subIntent
+ },
+ {
+ selector: getSubIntentState,
+ value: {
+ subIntent
+ }
+ },
+ {
+ selector: getSubIntentStatus,
+ value: {
+ status: '400',
+ message: 'test stripe init'
+ }
+ }
+ ]
+ })
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CheckoutComponent);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+
+ function getContReviewBtn(): HTMLButtonElement {
+ const btns: HTMLButtonElement = debugElement.nativeElement
+ .querySelector('button[label="Continue to review"]');
+ return btns;
+ }
+
+ it('continue to review button should be enabled when selecting an existing payment method', () => {
+ expect(getContReviewBtn().disabled).toBeTruthy()
+ component.selectedCC = component.pmOptions[1].value;
+ const chkbox: HTMLInputElement = debugElement.nativeElement
+ .querySelector('[type="checkbox"]');
+ chkbox.checked = false;
+ chkbox.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+ expect(getContReviewBtn().disabled).toBeFalsy();
+ });
+ it('should display total amount', () => {
+ const totalElt: HTMLInputElement = debugElement.nativeElement
+ .querySelector('#total');
+ expect(totalElt.value).toEqual('0.03');
+ });
+ it('should display card error', () => {
+ const errorElt: HTMLElement = debugElement.nativeElement
+ .querySelector('#card-element-errors');
+ expect(errorElt.innerText).toEqual('test stripe init');
+ });
+ it('should test createSelectedCard function', () => {
+ component.selectedCC = component.pmOptions[1].value;
+ expect(component.createSelectedCard()).toEqual(card)
+ });
+ it('should test createPaymentMethodPackage function', () => {
+ expect(component.createPaymentMethodPackage()).toEqual({
+ billing_details: {
+ name: subIntent.billingInfo.name,
+ address: subIntent.billingInfo.address
+ },
+ card: void 0,
+ defaultPM: false
+ })
+ });
+ it('should test submitting pre-selected payment method', () => {
+ spyOn(component, 'createSelectedCard');
+ expect(getContReviewBtn().disabled).toBeTruthy()
+ component.selectedCC = component.pmOptions[1].value;
+ const chkbox: HTMLInputElement = debugElement.nativeElement
+ .querySelector('[type="checkbox"]');
+ chkbox.checked = false;
+ chkbox.dispatchEvent(new Event('change'));
+ fixture.detectChanges();
+ expect(getContReviewBtn().disabled).toBeFalsy();
+ getContReviewBtn().click();
+ expect(component.createSelectedCard).toHaveBeenCalled();
+ });
+ it('should test submitting new payment method', () => {
+ spyOn(component, 'createPaymentMethodPackage');
+ spyOn(component, 'isFormValid').and.returnValue(true);
+ fixture.detectChanges();
+ expect(getContReviewBtn().disabled).toBeFalsy();
+ getContReviewBtn().click();
+ expect(component.createPaymentMethodPackage).toHaveBeenCalled();
+ });
+ it('choose existing payment should be null when create new payment radio button is checked', () => {
+ component.selectedCC = component.pmOptions[1].value;
+ const radioBtn: HTMLElement = debugElement.nativeElement
+ .querySelector('.ui-radiobutton-box');
+ expect(radioBtn.classList).not.toContain('ui-state-active')
+ radioBtn.click();
+ fixture.detectChanges();
+ expect(radioBtn.classList).toContain('ui-state-active')
+ expect(component.selectedCC).toEqual(null);
+ });
+ it('create new payment radio button should not be deselected when click multiple times', () => {
+ const radioBtn: HTMLElement = debugElement.nativeElement
+ .querySelector('.ui-radiobutton-box');
+ radioBtn.click();
+ fixture.detectChanges();
+ expect(radioBtn.classList).toContain('ui-state-active')
+ radioBtn.click();
+ fixture.detectChanges();
+ expect(radioBtn.classList).toContain('ui-state-active')
+ });
+});
\ No newline at end of file
diff --git a/Development/client/src/app/profile/checkout/checkout.component.ts b/Development/client/src/app/profile/checkout/checkout.component.ts
index 2d56c0e..df607b4 100644
--- a/Development/client/src/app/profile/checkout/checkout.component.ts
+++ b/Development/client/src/app/profile/checkout/checkout.component.ts
@@ -1,19 +1,18 @@
-import { AfterViewInit, ChangeDetectorRef, Component, Inject, LOCALE_ID, OnDestroy, OnInit } from '@angular/core';
+import { AfterViewInit, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { SelectItem } from 'primeng/api';
import { Store } from '@ngrx/store';
import { of, Subscription } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
import { getDefPM, getSubIntentPkgAmt, getSubIntentPkgCoupons, getSubIntentState, getSubIntentStatus } from '@app/reducers';
-import { ApplyDiscountPreview, Checkout, CheckoutTrial, ClearSubscriptionIntentStatus, ClearSubscriptionStatus, Compound, CreatePaymentMethod, GotoBillingAddress, GotoCheckoutReview, GotoMyServices, GotoServices, SetSubscriptionIntentPrevStage, UpdateAmount, UpdatePromoSavings } from '@app/actions/subscription.actions';
+import { ApplyDiscountPreview, Checkout, CheckoutTrial, ClearSubscriptionIntentStatus, ClearSubscriptionStatus, Compound, CreatePaymentMethod, GotoBillingAddress, GotoCheckoutReview, GotoMyServices, GotoServices, SetSubscriptionIntentPrevStage, UpdateAmount } from '@app/actions/subscription.actions';
import { PriceUsd, SubscriptionIntent, Status, PaidAmount, CheckoutPayment, TrialItem } from '@app/domain/models/subscription.model';
import { SubscriptionService } from '@app/domain/services/subscription.service';
import { SubTexts, SubAppErr, SUB, createSubStatus, SubStripe, Mode, hasVendorErr } from '../common';
import { AuthService } from '@app/domain/services/auth.service';
import { getSubscriptionState } from '@app/reducers';
-import { GC, Labels } from '@app/shared/global';
+import { GC } from '@app/shared/global';
import { DateUtils } from '@app/shared/utils';
-import { ActivePromo, ActivePromoService } from '@app/domain/services/active-promo.service';
const CHECKED = 'true';
@@ -25,7 +24,6 @@ const CHECKED = 'true';
export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
readonly SUB = SUB;
readonly SubTexts = SubTexts;
- readonly Labels = Labels;
sub$: Subscription;
subIntentPkg: SubscriptionIntent;
@@ -47,7 +45,7 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
amount: PaidAmount;
chkoutPmt: CheckoutPayment;
hasRefund: boolean;
- coupons: { id: string; name: string; }[];
+ coupons: string[];
discountErr: string;
disableCoupon: boolean;
@@ -58,27 +56,16 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
vendorErr: boolean;
- // Promo support (WI-4-v2: Option 2 - Inline badges + savings summary)
- activePromos: Map = new Map();
- paymentPromos: Map = new Map(); // Promos for payment line items
- refundPromos: Map = new Map(); // Promos for refund line items
- totalPromoSavings: number = 0; // Total promo discount amount (native interval)
-
constructor(
private readonly fb: FormBuilder,
private readonly store: Store<{}>,
private readonly cdRef: ChangeDetectorRef,
private readonly subSvc: SubscriptionService,
- readonly authSvc: AuthService,
- private readonly activePromoSvc: ActivePromoService,
- @Inject(LOCALE_ID) private localeId: string
+ private readonly authSvc: AuthService
) { }
ngOnInit(): void {
this.store.dispatch(new ClearSubscriptionIntentStatus());
- // Force refresh to get latest promo data from server
- this.activePromoSvc.refresh();
- this.loadActivePromos();
}
ngAfterViewInit(): void {
@@ -89,26 +76,12 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
this.cdRef.detectChanges();
}
- // Generate friendly display name for coupons
- private getCouponDisplayName(coupon: any): string {
- return coupon.name ||
- (coupon.percent_off ? `${coupon.percent_off}% off` :
- coupon.amount_off ? `$${(coupon.amount_off / 100).toFixed(2)} off` :
- coupon.id);
- }
-
private initSub$() {
const initCoupons = () => {
this.sub$.add(this.store.select(getSubIntentPkgCoupons).pipe(
map((coupons) => {
const hasCoupons = coupons?.length > 0;
- // Pass full coupon objects with id and name
- if (hasCoupons) {
- this.coupons = coupons?.map((coupon) => ({
- id: coupon.id,
- name: this.getCouponDisplayName(coupon)
- })) || [];
- }
+ if (hasCoupons) this.coupons = coupons?.map((coupon) => coupon.id) || [];
})
).subscribe({
error: err => {
@@ -137,16 +110,6 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
const toUpperCase = (brand: string) => `${brand.charAt(0).toLocaleUpperCase()}${brand.slice(1)}`;
this.pmOptions = this.subIntentPkg?.paymentMethods?.map(({ card, id }) => ({ label: `${toUpperCase(card.brand)} ${SubTexts.card} **** ${card.last4}`, value: `${id}` })) || [];
this.pmOptions.unshift({ label: $localize`:@@none:None`, value: '' });
- // Restore previously-selected card when navigating back from checkout-review.
- // subIntentPkg.card.pmId is preserved in the store by editCheckout() / back() —
- // neither dispatches a card-clearing action — so we can use it to skip the
- // getDefPM default and keep the user's explicit selection intact.
- const prevCardId = this.subIntentPkg?.card?.pmId;
- if (prevCardId && this.pmOptions.some((pm) => pm.value === prevCardId)) {
- this.selectedCC = prevCardId;
- this.radioCheck = null;
- return this.store.select(getSubscriptionState);
- }
return this.store.select(getDefPM).pipe(
take(1),
switchMap((defPM) => {
@@ -169,113 +132,38 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
if (this.isTrial) {
this.trialItems = this.subSvc.createTrialItems(this.subIntentPkg?.selPkg, this.subIntentPkg?.selAddons);
const total = this.trialItems?.map((item) => Number(item.amount)).reduce((t1, t2) => t1 + t2, 0);
-
- // Check for promo applicability on trial items (only if activePromos already loaded)
- this.checkTrialItemPromos();
-
- // Update total to discounted price (after calculating promo savings)
- const discountedTotal = total - this.totalPromoSavings;
- this.amount = { total: discountedTotal, totalTax: 0, totalExcludingTax: 0 };
-
- return;
+ return this.amount = { total, totalTax: 0, totalExcludingTax: 0 };
}
- let isDeferredPromo = false;
const hasOpenInvoices = this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE) || this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE) || this.authSvc.hasSubsWithStatus(SubStripe.UNPAID);
if (hasOpenInvoices) {
if (this.authSvc.hasSubsWithStatus(SubStripe.INCOMPLETE)) {
const invoices = subState.incomplete?.invoices;
- const currentInvoices = this.filterCurrentPeriodInvoices(invoices);
- this.chkoutPmt = this.subSvc.calcChkoutPayment(currentInvoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) });
+ this.chkoutPmt = this.subSvc.calcChkoutPayment(invoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) });
} else if (this.authSvc.hasSubsWithStatus(SubStripe.PAST_DUE) || this.authSvc.hasSubsWithStatus(SubStripe.OVERDUE)) {
const invoices = subState.pastDue?.invoices;
- const currentInvoices = this.filterCurrentPeriodInvoices(invoices);
- this.chkoutPmt = this.subSvc.calcChkoutPayment(currentInvoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) });
+ this.chkoutPmt = this.subSvc.calcChkoutPayment(invoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) });
} else if (this.authSvc.hasSubsWithStatus(SubStripe.UNPAID)) {
const invoices = subState.unpaid?.invoices;
- const currentInvoices = this.filterCurrentPeriodInvoices(invoices);
- this.chkoutPmt = this.subSvc.calcChkoutPayment(currentInvoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) });
+ this.chkoutPmt = this.subSvc.calcChkoutPayment(invoices, { subscriptions: this.authSvc.user.membership.subscriptions, coupon: this.subSvc.getInvCoupon(invoices) });
}
this.amount = this.subIntentPkg?.amount;
this.disableCoupon = true;
} else {
const upcomingInvoices = this.subIntentPkg?.upcomingInvoices;
- // Deferred promo path: qty changes immediately with no charge/refund (proration_behavior: 'none'
- // on actual subscription update). Invoice[0]'s proration lines are Stripe retrieveUpcoming
- // simulation artifacts that never execute. Use Invoice[1] (period_type: 'next', has_promo: true)
- // which directly represents the actual outcome: qty:N Aircraft Tracking FREE.
- // NOTE: Invoice[1] qty display requires the backend adoSubItems[0] fix to show correct quantity.
- // r975 fallback: has_promo may not be injected — also detect via pendingPromoDetails presence.
- isDeferredPromo = upcomingInvoices?.some(inv => inv?.period_type === 'next' && (inv?.has_promo === true || !!inv?.pendingPromoDetails)) ?? false;
- const invoicesToDisplay = isDeferredPromo
- ? upcomingInvoices?.filter(inv => inv?.period_type === 'next')
- : this.filterCurrentPeriodInvoices(upcomingInvoices);
- this.chkoutPmt = this.subSvc.calcChkoutPayment(invoicesToDisplay, { subscriptions: this.authSvc.user.membership.subscriptions });
+ this.chkoutPmt = this.subSvc.calcChkoutPayment(upcomingInvoices, { subscriptions: this.authSvc.user.membership.subscriptions });
const hasExistingCoupon = this.subIntentPkg?.coupons?.length > 0;
- // v3.1: coupon objects are sanitized from invoice responses; detect via discount refs
- const hasActiveDiscount = invoicesToDisplay?.some(inv => (inv?.total_discount_amounts?.length ?? 0) > 0);
if (hasExistingCoupon) {
- // Pass full coupon objects with id and name
- this.coupons = this.subIntentPkg?.coupons?.map((coupon) => ({
- id: coupon.id,
- name: this.getCouponDisplayName(coupon)
- })) || [];
+ this.coupons = this.subIntentPkg?.coupons?.map((coupon) => coupon.id) || [];
this.amount = { total: this.subIntentPkg?.amount.total, totalExcludingTax: this.subIntentPkg?.amount.totalExcludingTax, totalTax: this.subIntentPkg?.amount.totalTax, discount: this.subIntentPkg?.amount?.discount };
- this.disableCoupon = true;
} else {
this.coupons = [];
- this.amount = {
- total: this.chkoutPmt?.payment?.totalAmount || 0,
- totalExcludingTax: this.chkoutPmt?.payment?.totalAmount || 0,
- totalTax: this.chkoutPmt?.payment?.totalTax || 0,
- refundAmount: this.chkoutPmt?.refund
- ? Math.abs(this.chkoutPmt.refund.totalAmount || 0)
- : undefined
- };
- }
- // For deferred promo: Invoice[1] line `amount` is the pre-discount gross value.
- // calcChkoutPayment sums line amounts → gives the pre-coupon total (e.g. $49.95).
- // The actual charge is Invoice[1].total = 0 (100% coupon applied by Stripe).
- // Override amount with Invoice[1]'s real total fields.
- // For deferred promo: Invoice[1].total from Stripe's retrieveUpcoming is a simulation
- // artifact that may be negative (proration credit accounting). The actual charge today
- // is $0.00 — the 100% FREE coupon applies at next billing period, nothing is due now.
- if (isDeferredPromo) {
- this.amount = {
- total: 0,
- totalExcludingTax: 0,
- totalTax: 0
- };
- this.disableCoupon = true;
- } else if (hasActiveDiscount) {
- // Subscription already has a Stripe discount applied — hide coupon input
- this.disableCoupon = true;
+ this.amount = { total: this.chkoutPmt?.payment?.totalAmount || 0, totalExcludingTax: this.chkoutPmt?.payment?.totalAmount || 0, totalTax: this.chkoutPmt?.payment?.totalTax || 0 };
}
this.store.dispatch(new UpdateAmount(this.amount));
- // Invoice[1] has no proration lines → calcInvoice path → no refund split naturally.
- this.hasRefund = !!this.chkoutPmt.refund && !isDeferredPromo;
- }
- if (this.hasRefund === undefined) {
- this.hasRefund = !!this.chkoutPmt?.refund;
- }
-
- // Check for applicable promos after chkoutPmt is populated
- this.checkApplicablePromos();
-
- // For deferred promo: override promoSavings — Invoice[1] line amounts are 0
- // because Stripe pre-applies the 100% coupon in retrieveUpcoming preview.
- // calcPromoSavings uses unit_amount which is correct, but paymentPromos may be
- // empty if the addon already exists (existing sub check). Compute directly from
- // unit_amount × quantity as the "would-have-paid" gross value.
- if (isDeferredPromo) {
- const nextInvoice = this.subIntentPkg?.upcomingInvoices?.find((inv: any) => inv?.period_type === 'next');
- const deferredSavings = nextInvoice?.lines?.data?.reduce((sum: number, line: any) => {
- return sum + (line.price?.unit_amount ?? 0) * (line.quantity ?? 1);
- }, 0) ?? 0;
- this.totalPromoSavings = deferredSavings;
- this.store.dispatch(new UpdatePromoSavings(this.totalPromoSavings));
}
+ this.hasRefund = !!this.chkoutPmt.refund;
})
).subscribe({
error: err => {
@@ -320,25 +208,6 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
&& !this.vendorErr;
}
- /**
- * Check if there are any active promos
- * Used to hide coupon section when promos are active (promos and coupons are mutually exclusive)
- */
- get hasActivePromos(): boolean {
- return this.paymentPromos?.size > 0 || this.refundPromos?.size > 0;
- }
-
- /** Payment section header — always "Added" regardless of whether there is a refund or not */
- get paymentSectionLabel(): string {
- return SubTexts.added;
- }
-
- /** Refund section header: "Removed (Refunding)" when non-zero credit, otherwise "Removed" */
- get refundSectionLabel(): string {
- const totalRefund = Math.abs(this.chkoutPmt?.refund?.totalAmount || 0);
- return totalRefund !== 0 ? SubTexts.removedRefunding : SubTexts.removed;
- }
-
isFormValid() {
return (this.selectedCC ? true : false || this.form?.status === 'VALID') && this.status?.code !== SubAppErr._500_ERR;
}
@@ -368,13 +237,7 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
if (hasLoaded) {
this.stripeLoaded = hasLoaded;
const fromCheckRevStage = this.prevStage === SUB.CHKOUT_REV;
- // When returning from checkout-review with a previously selected existing card,
- // the card is already restored in ngOnInit via subIntentPkg.card.pmId.
- // Treat this case the same as a normal page load with existing PMs so that
- // the Stripe new-card element is dismounted and selectedCC is NOT wiped.
- const prevCardId = this.subIntentPkg?.card?.pmId;
- const prevCardInOptions = !!(prevCardId && this.pmOptions.some((pm) => pm.value === prevCardId));
- const canDismountStripeElt = this.hasExistingPMs && (!fromCheckRevStage || prevCardInOptions);
+ const canDismountStripeElt = this.hasExistingPMs && !fromCheckRevStage;
if (canDismountStripeElt) {
this.dismount = true;
} else {
@@ -431,11 +294,11 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
if (this.trialPmSelected) {
if (this.selectedCC) {
- return this.store.dispatch(new CheckoutTrial({ ...subs, pmtMethod: { exPmtMeth: this.getSelCard() }, mode: Mode.TRIALING, amount: this.amount }));
+ return this.store.dispatch(new CheckoutTrial({ ...subs, pmtMethod: { exPmtMeth: this.getSelCard() }, mode: Mode.TRIALING }));
}
- return this.store.dispatch(new CheckoutTrial({ ...subs, pmtMethod: { newPmtMeth: this.getNewPm() }, mode: Mode.TRIALING, amount: this.amount }));
+ return this.store.dispatch(new CheckoutTrial({ ...subs, pmtMethod: { newPmtMeth: this.getNewPm() }, mode: Mode.TRIALING }));
}
- return this.store.dispatch(new CheckoutTrial({ ...subs, mode: Mode.TRIALING, amount: this.amount }));
+ return this.store.dispatch(new CheckoutTrial({ ...subs, mode: Mode.TRIALING }));
}
private submitPayment() {
@@ -447,25 +310,8 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
back() {
try {
- const actionMap = {
- [SUB.BILL_ADR]: new GotoBillingAddress(),
- [SUB.CHKOUT_REV]: new GotoCheckoutReview(),
- [SUB.SERVICES]: new GotoServices(),
- 'default': new GotoMyServices()
- };
-
- // Validate action before dispatching to prevent undefined actions
- const targetAction = actionMap[this.prevStage] || actionMap['default'];
-
- if (!targetAction) {
- console.error('[Checkout] Invalid prevStage value:', this.prevStage, '- defaulting to MyServices');
- return this.store.dispatch(new GotoMyServices());
- }
-
- this.store.dispatch(new Compound([
- new SetSubscriptionIntentPrevStage(SUB.CHKOUT),
- targetAction
- ]));
+ const actionMap = { [SUB.BILL_ADR]: new GotoBillingAddress(), [SUB.CHKOUT_REV]: new GotoCheckoutReview(), [SUB.SERVICES]: new GotoServices(), 'default': new GotoMyServices() }
+ this.store.dispatch(new Compound([new SetSubscriptionIntentPrevStage(SUB.CHKOUT), actionMap[this.prevStage ? this.prevStage : 'default']]));
} catch (err) {
console.log(err);
this.status = createSubStatus(SubAppErr.CHKOUT_ERR);
@@ -511,302 +357,6 @@ export class CheckoutComponent implements OnInit, OnDestroy, AfterViewInit {
}
}
- // ============================================================================
- // PROMO SUPPORT (WI-4-v2: Inline Badges Only)
- // ============================================================================
-
- /**
- * Load active promos and build lookup maps
- * Creates Map for exact and type-only matches
- */
- private loadActivePromos(): void {
- this.activePromoSvc.getActivePromos().subscribe(promos => {
- this.activePromos = new Map();
- promos.forEach(p => {
- // Exact match by priceKey
- if (p.priceKey) {
- this.activePromos.set(p.priceKey, p);
- } else if (p.type) {
- // Type-only promo (priceKey = null in backend)
- 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);
- }
- });
-
- // Check regular checkout promos (will only process if chkoutPmt is also ready)
- this.checkApplicablePromos();
-
- // Check trial item promos (will only process if trialItems are ready)
- this.checkTrialItemPromos();
-
- // For trial flow: re-apply promo savings to this.amount now that promos are loaded.
- // initPage() sets this.amount before activePromos arrive (totalPromoSavings=0 at that
- // point), so the amount is stale (full price). Once promos are fetched and
- // checkTrialItemPromos() has recalculated totalPromoSavings, recompute the total.
- if (this.isTrial && this.trialItems?.length > 0 && this.totalPromoSavings > 0) {
- const grossTotal = this.trialItems.map(item => Number(item.amount)).reduce((s, v) => s + v, 0);
- this.amount = { total: grossTotal - this.totalPromoSavings, totalTax: 0, totalExcludingTax: 0 };
- this.store.dispatch(new UpdateAmount(this.amount));
- }
- });
- } /**
- * Check which line items have applicable promos
- * Populates paymentPromos and refundPromos maps
- * CRITICAL: Only applies promos to NEW subscriptions (no existing subscription of same type)
- * NOTE: Can be called multiple times safely - will only process if both activePromos and chkoutPmt are ready
- */
- private checkApplicablePromos(): void {
- // Guard: Need both activePromos and chkoutPmt to be ready
- if (!this.activePromos || this.activePromos.size === 0 || !this.chkoutPmt) {
- return;
- }
-
- // Check payment line items
- if (this.chkoutPmt?.payment?.lineItems) {
- this.paymentPromos = this.getPromosForLineItems(this.chkoutPmt.payment.lineItems);
- }
-
- // Check refund line items (if any)
- if (this.chkoutPmt?.refund?.lineItems) {
- this.refundPromos = this.getPromosForLineItems(this.chkoutPmt.refund.lineItems);
- }
-
- // Calculate total promo savings for display
- this.calculateTotalPromoSavings();
- }
-
- /**
- * Get applicable promos for a list of line items
- * Returns Map for items that have promos
- * CRITICAL: Only includes items with positive amounts (actual charges)
- * Refunds (negative amounts) are credits, not promo discounts
- */
- private getPromosForLineItems(lineItems: any[]): Map {
- const promosMap = new Map();
- if (!lineItems || lineItems.length === 0) return promosMap;
-
- lineItems.forEach(item => {
- // Skip refund line items (negative amounts) - they're credits, not promo discounts
- if (item.amount < 0) {
- return;
- }
-
- const promo = this.getPromoForLookupKey(item.price?.lookup_key);
- if (promo) {
- promosMap.set(item.price?.lookup_key, promo);
- }
- });
-
- return promosMap;
- }
-
- /**
- * Get promo for a specific lookup key
- * Checks if item is eligible (new subscription) and returns matching promo
- * CRITICAL: Only applies promos to NEW subscriptions
- * @param lookupKey Package or addon lookup key (e.g., 'ess_3', 'addon_1')
- * @returns ActivePromo if exists and item is new subscription, null otherwise
- */
- private getPromoForLookupKey(lookupKey: string): ActivePromo | null {
- if (!lookupKey) return null;
-
- // Determine if this is a package or addon
- const isPackage = lookupKey.startsWith('ess_') || lookupKey.startsWith('ent_');
- const type: 'package' | 'addon' = isPackage ? 'package' : 'addon';
-
- // Check if user has existing subscription of this type
- const userSubs = this.authSvc.user?.membership?.subscriptions || [];
-
- if (type === 'package') {
- // For packages: Check if user has ANY package subscription (packages are mutually exclusive)
- // AGNavSubscription has type field: 'package' | 'addon'
- const hasAnyPackageSubscription = userSubs.some(sub => sub.type === 'package');
-
- // Only show promo if user has NO package subscriptions (new subscription)
- if (hasAnyPackageSubscription) {
- return null;
- }
- }
- // For addons: If addon is in checkout flow, backend already validated user doesn't have it
- // No additional check needed (backend filtering ensures this)
-
- // Priority 1: Exact match by priceKey
- const exactMatch = this.activePromos.get(lookupKey);
- if (exactMatch) return exactMatch;
-
- // Priority 2: Type-only match (priceKey = null in backend = applies to all of type)
- const typeOnlyPromo = this.activePromos.get(`${type}_all`);
- if (typeOnlyPromo) return typeOnlyPromo;
-
- return null;
- }
-
- /**
- * Calculate total promo savings amount
- * Calculates discount based on promo discountType and discountValue
- * NOTE: Preview invoice doesn't have discount applied yet, so we calculate it manually
- */
- private calculateTotalPromoSavings(): void {
- // Calculate payment promo savings using centralized service method
- const paymentSavings = this.subSvc.calculatePromoSavings(
- this.chkoutPmt?.payment?.lineItems,
- this.paymentPromos
- );
-
- // Calculate refund promo savings (if any) using centralized service method
- const refundSavings = this.subSvc.calculatePromoSavings(
- this.chkoutPmt?.refund?.lineItems,
- this.refundPromos
- );
-
- // Total savings at native interval (matches non-promo display)
- this.totalPromoSavings = paymentSavings + refundSavings;
-
- // Store in Redux state for use in checkout-review and checkout-confirm
- this.store.dispatch(new UpdatePromoSavings(this.totalPromoSavings));
-
- // Re-dispatch corrected total so payment-amount receives post-promo value.
- // payment-amount's design contract: [totalAmount] must already be post-discount.
- // Use chkoutPmt.payment.totalAmount as the stable base — NOT this.amount.total,
- // which would be re-decremented on every call (this method fires from both
- // initPage() and the loadActivePromos() HTTP callback).
- if (this.totalPromoSavings > 0 && this.amount && this.chkoutPmt) {
- const baseTotal = this.chkoutPmt.payment?.totalAmount || 0;
- const correctedTotal = Math.max(0, baseTotal - this.totalPromoSavings);
- this.amount = { ...this.amount, total: correctedTotal };
- this.store.dispatch(new UpdateAmount(this.amount));
- }
- }
-
-
-
- // ============================================================================
- // TRIAL CHECKOUT SUPPORT (Solution A: Inline Charge Date Banner)
- // ============================================================================
-
- /**
- * Check for promos applicable to trial items
- * Similar to checkApplicablePromos() but for trial flow
- */
- private checkTrialItemPromos(): void {
- if (!this.trialItems || this.trialItems.length === 0) return;
-
- this.paymentPromos = new Map();
- let totalSavings = 0;
-
- this.trialItems.forEach(item => {
- const lookupKey = item.price?.lookup_key;
- if (!lookupKey) return;
-
- // Try exact match first
- let promo = this.activePromos.get(lookupKey);
-
- // If no exact match, try type-only match
- if (!promo) {
- const type = lookupKey.startsWith('addon_') ? 'addon' : 'package';
- promo = this.activePromos.get(`${type}_all`);
- }
-
- if (promo) {
- this.paymentPromos.set(lookupKey, promo);
-
- // Calculate savings for this item
- const originalAmount = item.price.unit_amount * (item.quantity || 1);
- const discountedAmount = this.calculateDiscountedAmount(originalAmount, promo);
- totalSavings += (originalAmount - discountedAmount);
- }
- });
-
- this.totalPromoSavings = totalSavings;
- this.store.dispatch(new UpdatePromoSavings(totalSavings));
- }
-
- /**
- * Calculate discounted amount based on promo type
- * Uses centralized calculation from SubscriptionService
- */
- private calculateDiscountedAmount(originalAmount: number, promo: ActivePromo): number {
- return this.subSvc.calculateDiscountedAmount(originalAmount, promo);
- }
-
- /**
- * Get charge date from user's active trial subscription
- * Falls back to trial item if subscription not found
- */
- getTrialChargeDate(): string {
- // Try to get trialEnd from user's active subscriptions first
- // Note: AGNavSubscription interface doesn't include trialEnd, but Stripe API returns it
- const activeSub = this.authSvc.user?.membership?.subscriptions?.find(
- sub => sub.status === 'trialing'
- ) as any;
-
- // Check for trial end date (trial_end is the correct field name in snake_case)
- const trialEndTimestamp = activeSub?.trial_end;
-
- if (trialEndTimestamp) {
- const chargeDate = new Date(trialEndTimestamp * 1000);
- return chargeDate.toLocaleDateString(this.localeId, {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- }
-
- // Fallback: Try to get from trial items (if populated)
- if (this.trialItems && this.trialItems.length > 0) {
- const firstItem = this.trialItems[0];
- if (firstItem.trialEnd) {
- const chargeDate = new Date(firstItem.trialEnd * 1000);
- return chargeDate.toLocaleDateString(this.localeId, {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- }
- }
-
- return '';
- } /**
- * Get total amount including tax
- */
- getTotalWithTax(): number {
- return (this.amount?.total || 0) + (this.amount?.totalTax || 0);
- }
-
- /**
- * Format currency for display
- */
- formatCurrency(amountInCents: number): string {
- return (amountInCents / 100).toFixed(2);
- }
-
- /**
- * Filter invoices to only include current period transactions.
- * Excludes next billing period invoices (period_type: "next") from checkout display.
- *
- * Backend v3.1 dual-invoice response: When deferred promo detected (100% off + active + auto-renew),
- * returns TWO invoices - current period proration + next period preview.
- *
- * Checkout page should ONLY display current period (what user pays NOW).
- * Next period invoice is for manage-subscription page (Issue 2) to show future billing preview.
- *
- * @param invoices - Array of invoices from backend API
- * @returns Filtered array containing only current period invoices
- */
- private filterCurrentPeriodInvoices(invoices: any[]): any[] {
- if (!invoices || invoices.length === 0) {
- return [];
- }
-
- // Filter out invoices with period_type === "next"
- // Include: invoices with NO period_type field (standard Stripe) OR period_type === "current"
- // Exclude: invoices with period_type === "next" (future billing cycle)
- return invoices.filter(inv => !inv.period_type || inv.period_type === 'current');
- }
-
ngOnDestroy(): void {
this.sub$?.unsubscribe();
}
diff --git a/Development/client/src/app/profile/common.ts b/Development/client/src/app/profile/common.ts
index eb0edd4..779ca19 100644
--- a/Development/client/src/app/profile/common.ts
+++ b/Development/client/src/app/profile/common.ts
@@ -11,7 +11,7 @@ export const DELAY = 1000;
export const TAKE = 3;
export enum InvType { CHARGE = 'charge', INVOICE = 'invoice' };
export enum SubType { PACKAGE = 'package', ADDON = 'addon' };
-export enum SubKeys { ESS_1 = 'ess_1', ESS_1_1 = 'ess_1_1', ESS_2 = 'ess_2', ESS_3 = 'ess_3', ESS_4 = 'ess_4', ESS_5 = 'ess_5', ENT_1 = 'ent_1', ENT_2 = 'ent_2', ENT_3 = 'ent_3', ENT_4 = 'ent_4', ENT_5 = 'ent_5', TRACKING = 'addon_1' };
+export enum SubKeys { ESS_1 = 'ess_1', ESS_2 = 'ess_2', ESS_3 = 'ess_3', ESS_4 = 'ess_4', ESS_5 = 'ess_5', ENT_1 = 'ent_1', ENT_2 = 'ent_2', ENT_3 = 'ent_3', ENT_4 = 'ent_4', ENT_5 = 'ent_5', TRACKING = 'addon_1' };
export enum Mode { REGULAR, TRIALING, CONTINUE_TRIAL, UPDATE_BIL_ADR, UNPAID };
export const ACTIVE = 'active';
export const PACKAGE_ACTIVE = 'pkgActive';
@@ -22,16 +22,14 @@ export const STRIPE_ELT_STYLE = {
color: '#212121',
fontFamily: 'Roboto, "Helvetica Neue", sans-serif',
fontSmoothing: 'antialiased',
- '::placeholder': { color: '#212121' },
- padding: '0.5rem'
+ '::placeholder': { color: '#212121' }
},
invalid: {
fontSize: '14px',
fontFamily: 'Roboto, "Helvetica Neue", sans-serif',
fontSmoothing: 'antialiased',
color: 'red',
- '::placeholder': { color: '#212121' },
- padding: '0.5rem'
+ '::placeholder': { color: '#212121' }
}
}
@@ -39,111 +37,6 @@ export const STRIPE_BIL_ADDR_STYLE = {
variables: { borderRadius: '1px', fontFamily: 'Roboto, "Helvetica Neue", sans-serif', colorText: '#000000', colorPrimary: '#4CAF50' }
}
-// Promo Translation Keys
-export const PromoLabels = {
- // Package Promos
- PROMO_ESS_FREE: $localize`:Promo name@@PROMO_ESS_FREE:Essential Free Trial`,
- PROMO_ESS_FREE_DESC: $localize`:Promo desc@@PROMO_ESS_FREE_DESC:Get Essential tier free until specified date`,
- PROMO_ENT_FREE: $localize`:Promo name@@PROMO_ENT_FREE:Enterprise Free Trial`,
- PROMO_ENT_FREE_DESC: $localize`:Promo desc@@PROMO_ENT_FREE_DESC:Get Enterprise tier free until specified date`,
-
- // Addon Promos
- PROMO_ADDON_FREE: $localize`:Promo name@@PROMO_ADDON_FREE:Addon Free Until April`,
- PROMO_ADDON_FREE_DESC: $localize`:Promo desc@@PROMO_ADDON_FREE_DESC:Get addon features free until April 2026`,
-
- // Generic fallbacks
- PROMO_GENERIC_FREE: $localize`:Promo name@@PROMO_GENERIC_FREE:Free Promotion`,
- PROMO_GENERIC_FREE_DESC: $localize`:Promo desc@@PROMO_GENERIC_FREE_DESC:Limited time free access`,
-};
-
-/**
- * Promo Error Messages (Admin-facing)
- * Used in: subscription-mgt.component.ts (admin promo management UI)
- * Backend error types defined in: server/helpers/constants.js lines 142-150
- * Backend thrown in: server/controllers/main.js lines 535, 547, 690, 695
- */
-export const PromoErrors = {
- /**
- * Error: Promo not found during lookup
- * Backend error type: PROMO_NOT_FOUND
- * Thrown in: server/controllers/main.js lines 690, 695
- */
- PROMO_NOT_FOUND: $localize`:Admin error - promo not found@@promoNotFound:Promotion not found. Please verify the promotion ID and try again.`,
-
- /**
- * Error: Active promo already exists for this package/addon combination
- * Backend error type: PROMO_DUPLICATE_TYPE_PRICEKEY
- * Thrown in: server/controllers/main.js line 535
- */
- PROMO_DUPLICATE_TYPE_PRICEKEY: $localize`:Admin error - duplicate promo type/price@@promoDupTypePriceKey:A promotion already exists for this package/addon combination. Please update the existing promotion or disable it before creating a new one.`,
-
- /**
- * Error: This coupon is already used by another active promo
- * Backend error type: PROMO_DUPLICATE_COUPON
- * Thrown in: server/controllers/main.js line 547
- */
- PROMO_DUPLICATE_COUPON: $localize`:Admin error - duplicate coupon@@promoDupCoupon:This Stripe coupon is already used by another active promotion. Each coupon can only be used in one promotion at a time.`,
-
- /**
- * Error: Date ranges overlap with existing promo
- * Backend error type: PROMO_OVERLAPPING_DATES
- * Note: Not yet implemented in backend r955 (reserved for future use)
- */
- PROMO_OVERLAPPING_DATES: $localize`:Admin error - overlapping dates@@promoOverlapDates:Promotion dates overlap with an existing promotion. Please adjust the valid date range.`,
-
- /**
- * Error: Stripe coupon lookup failed
- * Backend error type: PROMO_COUPON_NOT_FOUND
- * Note: Not yet implemented in backend r955 (reserved for future use)
- */
- PROMO_COUPON_NOT_FOUND: $localize`:Admin error - Stripe coupon not found@@promoCouponNotFound:Stripe coupon not found. Please verify the coupon ID exists in Stripe dashboard.`,
-
- /**
- * Generic promo error (fallback for unknown error types)
- */
- PROMO_GENERIC_ERROR: $localize`:Admin error - generic promo error@@promoGenericError:Failed to manage promotion. Please check the error details and try again.`,
-
- /**
- * Error: Invalid coupon or promotion code (user-facing)
- * Backend error type: PROMO_INVALID_COUPON (NEW in r959)
- * Thrown in: server/controllers/subscription.js (resolveCouponCode, getCoupon_get)
- * Use case: User enters invalid/restricted coupon at checkout
- */
- PROMO_INVALID_COUPON: $localize`:User error - invalid coupon@@promoInvalidCoupon:Invalid promotion code. Please check and try again.`,
-
- /**
- * Error: Coupon restricted to specific customers
- * Backend error type: PROMO_INVALID_COUPON (customer restriction variant)
- */
- PROMO_RESTRICTED_CUSTOMER: $localize`:User error - customer restriction@@promoRestrictedCustomer:This promotion is not available for your account.`,
-
- /**
- * Error: Coupon doesn't apply to selected products
- * Backend error type: PROMO_INVALID_COUPON (product restriction variant)
- */
- PROMO_RESTRICTED_PRODUCT: $localize`:User error - product restriction@@promoRestrictedProduct:This promotion does not apply to the selected package.`,
-
- /**
- * Error: Coupon restricted to first-time customers only
- * Backend error type: PROMO_INVALID_COUPON (first-time transaction restriction)
- */
- PROMO_FIRST_TIME_ONLY: $localize`:User error - first time only@@promoFirstTimeOnly:This promotion is only available for first-time customers.`,
-
- /**
- * Error: Coupon has expired
- * Backend error type: PROMO_INVALID_COUPON (expired variant)
- * Thrown in: server/controllers/subscription.js (resolveCouponCode)
- */
- PROMO_EXPIRED: $localize`:User error - coupon expired@@promoExpired:This promotion has expired.`,
-
- /**
- * Error: Coupon reached maximum redemption limit
- * Backend error type: PROMO_INVALID_COUPON (max redemptions variant)
- * Thrown in: server/controllers/subscription.js (resolveCouponCode)
- */
- PROMO_MAX_REDEMPTIONS: $localize`:User error - max redemptions@@promoMaxRedemptions:This promotion has reached its maximum usage limit.`
-};
-
// Application errors
export const SubAppErr = Object.freeze({
BIL_ADDR_ERR: 'subMsgBillAddrErr',
@@ -198,12 +91,7 @@ export const SubAppErr = Object.freeze({
INVALID_DATE: 'subInvalidDateErr',
AC_LIST_ERR: 'subAcListErr',
APP_VENDOR_NOT_FOUND: 'app_vendor_not_found',
- LOCAL_VENDOR_NOT_FOUND: 'local_vendor_not_found',
- PROMO_NOT_FOUND_ERR: 'subMsgPromoNotFoundErr',
- PROMO_DUPLICATE_TYPE_PRICEKEY_ERR: 'subMsgPromoDupTypePriceKeyErr',
- PROMO_DUPLICATE_COUPON_ERR: 'subMsgPromoDupCouponErr',
- PROMO_OVERLAPPING_DATES_ERR: 'subMsgPromoOverlapDatesErr',
- PROMO_COUPON_NOT_FOUND_ERR: 'subMsgPromoCouponNotFoundErr'
+ LOCAL_VENDOR_NOT_FOUND: 'local_vendor_not_found'
});
// Stripe specific constants
@@ -293,7 +181,6 @@ export const SubTexts: any = Object.freeze({
textAddPMSuccess: $localize`:@@textAddPMSuccess:Payment method added successfully.`,
textEditPMSuccess: $localize`:@@textAddPMSuccess:Your payment method has been successfully updated to #card#.`,
textDeletePM: $localize`:@@textDeletePM:You are about to delete the payment method #card#. Do you wish to continue?`,
- textPromoApplied: $localize`:@@textPromoApplied:Promotional pricing has been applied to your subscription. See details below.`,
textDeletePMSuccess: $localize`:@@textDeletePMSuccess:Successfully deleted your default payment method #card#.`,
textDeletePMFailed: $localize`:@@textDeletePMFailed:Removal of #card# as your default payment method was unsuccessful. Please contact support for help.`,
textChangePMSuccess: $localize`:@@textChangePMSuccess:Successfully changed your default payment method to #card#`,
@@ -304,7 +191,7 @@ export const SubTexts: any = Object.freeze({
textTrkChng: $localize`:@@textTrkChng:The quantity of your tracked aircraft has changed to #quantity#. Please review your selections.`,
textPkgTrkChng: $localize`:@@textPkgTrkChng:Your package has been upgraded to #pkg#, allowing a maximum of #maxAC# aircraft. The tracking quantity has been adjusted to #quantity#. Please review your active and tracked aircraft selections.`,
- textReviewAC: $localize`:@@textReviewAC:Verify aircraft selections and click Update to apply changes.`,
+ textReviewAC: $localize`:@@textReviewAC:Please verify your package active and tracking aircraft selections and click [Update] to apply changes.`,
textUpdateAC: $localize`:@@textUpdateAC:Confirm the update to your aircraft service selections. Click [Yes] to continue.`,
textNewPkg: $localize`:@@textNewPkg:You have selected a new package #pkg# allowing up to #maxAC# aircraft. Please review your active aircraft selections.`,
@@ -327,7 +214,7 @@ export const SubTexts: any = Object.freeze({
labelChngSub: $localize`:@@labelChngSub:Modify Your Subscription Plan`,
labelChngTrial: $localize`:@@labelChngTrial:Modify Your Trial Plan`,
labelUpdateAddr: $localize`:@@labelUpdateAddr:Update Address`,
- labelContTrial: $localize`:@@labelContTrial:Continue Subscription After Trial`,
+ labelContTrial: $localize`:@@labelContTrial:Proceed with Subscription Post-Trial`,
labelAutoRenew: $localize`:@@labelAutoRenew:Auto Renew`,
labelEdit: $localize`:@@labelEdit:Edit`,
labelChange: $localize`:@@labelEdit:Change`,
@@ -337,7 +224,6 @@ export const SubTexts: any = Object.freeze({
labelResolvePM: $localize`:@@labelResolvePM:Please update your payment method.`,
labelApply: $localize`:@@labelApply:Apply`,
labelSub: $localize`:@@labelSub:Subscription`,
- labelChngBilAddr: $localize`:@@labelChngBilAdr:Change Billing Address`,
unlimited: $localize`:@@unlimited:Unlimited`,
contact: $localize`:@@contact:contact`,
@@ -351,13 +237,8 @@ export const SubTexts: any = Object.freeze({
code: $localize`:@@code:Code`,
off: $localize`:@@off:off`,
dollar: $localize`:@@dollar:Dollar`,
- chargesToday: $localize`:@@chargesToday:Charges Today`,
- added: $localize`:@@added:Added`,
- planRefund: $localize`:@@planRefund:Plan Refund`,
- removed: $localize`:@@removed:Removed`,
- removedAndRefunding: $localize`:@@removedAndRefunding:Removed (and Refunding)`,
- removedRefunding: $localize`:@@removedRefunding:Removed (Refunding)`,
- refunding: $localize`:@@refunding:Refunding`,
+ payment: $localize`:@@payment:Payment`,
+ refund: $localize`:@@refund:Refund`,
trial: $localize`:@@trial:Trial`,
paid: $localize`:@@paid:Paid`,
lastTrial: $localize`:@@lastTrial:Last Trial`,
@@ -387,7 +268,6 @@ export const SubTexts: any = Object.freeze({
export const SUB_NAME = Object.freeze({
[SubKeys.ESS_1]: `${SubTexts.agmEss} 1`,
- [SubKeys.ESS_1_1]: `${SubTexts.agmEss} 1 Plus`,
[SubKeys.ESS_2]: `${SubTexts.agmEss} 2`,
[SubKeys.ESS_3]: `${SubTexts.agmEss} 3`,
[SubKeys.ESS_4]: `${SubTexts.agmEss} 4`,
@@ -403,75 +283,22 @@ export const SUB_NAME = Object.freeze({
export enum SERVICE_TYPE { ESS = 'essential', ENT = 'enterprise', ADDON = 'addon' };
const X1 = '1 x';
export const UNLIMITED = $localize`:@@unlimted:Unlimited`;
-export const EMPTY = '';
+const EMPTY = '';
const TEN_PLUS = '10+';
export const subPlans = {
- [SubKeys.ESS_1]: {
- priceId: 1, maxVehicles: 1, maxAcres: '50000', desc: `${X1} ${SUB_NAME[SubKeys.ESS_1]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_1], price: 99500, level: 1, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_1, interval: 'year'
- },
- [SubKeys.ESS_1_1]: {
- priceId: 1.1, maxVehicles: 1, maxAcres: null, desc: `${X1} ${SUB_NAME[SubKeys.ESS_1_1]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_1_1], price: 139500, level: 1, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_1_1, interval: 'year'
- },
- [SubKeys.ESS_2]: {
- priceId: 2, maxVehicles: 2, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_2]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_2], price: 249500, level: 2, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_2, interval: 'year'
- },
- [SubKeys.ESS_3]: {
- priceId: 3, maxVehicles: 5, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_3]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_3], price: 349500, level: 3, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_3, interval: 'year'
- },
- [SubKeys.ESS_4]: {
- priceId: 4, maxVehicles: 10, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_4]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_4], price: 449500, level: 4, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_4, interval: 'year'
- },
- [SubKeys.ESS_5]: {
- priceId: 5, maxVehicles: UNLIMITED, maxAcres: SubTexts.unlimited, desc: `${X1} ${SUB_NAME[SubKeys.ESS_5]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_5], price: 899000, level: 5, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_5, interval: 'year'
- },
- [SubKeys.ENT_1]: {
- priceId: 6, maxVehicles: 1, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_1]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_1], price: 149500, level: 11, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_1, interval: 'year'
- },
- [SubKeys.ENT_2]: {
- priceId: 7, maxVehicles: 1, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_2]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_2], price: 249500, level: 12, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_2, interval: 'year'
- },
- [SubKeys.ENT_3]: {
- priceId: 8, maxVehicles: 5, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_3]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_3], price: 499500, level: 13, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_3, interval: 'year'
- },
- [SubKeys.ENT_4]: {
- priceId: 9, maxVehicles: 10, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_4]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_4], price: 499500, level: 14, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_4, interval: 'year'
- },
- [SubKeys.ENT_5]: {
- priceId: 10, maxVehicles: UNLIMITED, maxAcres: SubTexts.unlimited, desc: `${X1} ${SUB_NAME[SubKeys.ENT_5]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_5], price: SubTexts.contact, Vehicles: TEN_PLUS, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_5, interval: 'year'
-
- },
- [SubKeys.TRACKING]: {
- priceId: 1, desc: `${SUB_NAME[SubKeys.TRACKING]} ${SubTexts.priceMonthly}`, name: SUB_NAME[SubKeys.TRACKING], price: 4995, level: 0, type: SERVICE_TYPE.ADDON, lookupKey: SubKeys.TRACKING, interval: 'month'
- },
+ [SubKeys.ESS_1]: { priceId: 1, maxVehicles: 1, maxAcres: '50000', desc: `${X1} ${SUB_NAME[SubKeys.ESS_1]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_1], price: 99500, level: 1, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_1 },
+ [SubKeys.ESS_2]: { priceId: 2, maxVehicles: 1, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_2]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_2], price: 249500, level: 2, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_2 },
+ [SubKeys.ESS_3]: { priceId: 3, maxVehicles: 5, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_3]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_3], price: 349500, level: 3, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_3 },
+ [SubKeys.ESS_4]: { priceId: 4, maxVehicles: 10, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ESS_4]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_4], price: 449500, level: 4, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_4 },
+ [SubKeys.ESS_5]: { priceId: 5, maxVehicles: UNLIMITED, maxAcres: SubTexts.unlimited, desc: `${X1} ${SUB_NAME[SubKeys.ESS_5]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ESS_5], price: 899000, Vehicles: EMPTY, type: SERVICE_TYPE.ESS, lookupKey: SubKeys.ESS_5 },
+ [SubKeys.ENT_1]: { priceId: 6, maxVehicles: 1, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_1]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_1], price: 149500, level: 11, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_1 },
+ [SubKeys.ENT_2]: { priceId: 7, maxVehicles: 1, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_2]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_2], price: 249500, level: 12, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_2 },
+ [SubKeys.ENT_3]: { priceId: 8, maxVehicles: 5, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_3]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_3], price: 499500, level: 13, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_3 },
+ [SubKeys.ENT_4]: { priceId: 9, maxVehicles: 10, maxAcres: UNLIMITED, desc: `${X1} ${SUB_NAME[SubKeys.ENT_4]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_4], price: 499500, level: 14, Vehicles: EMPTY, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_4 },
+ [SubKeys.ENT_5]: { priceId: 10, maxVehicles: UNLIMITED, maxAcres: SubTexts.unlimited, desc: `${X1} ${SUB_NAME[SubKeys.ENT_5]} ${SubTexts.priceYearly}`, name: SUB_NAME[SubKeys.ENT_5], price: SubTexts.contact, Vehicles: TEN_PLUS, type: SERVICE_TYPE.ENT, lookupKey: SubKeys.ENT_5 },
+ [SubKeys.TRACKING]: { priceId: 1, desc: `${SUB_NAME[SubKeys.TRACKING]} ${SubTexts.priceMonthly}`, name: SUB_NAME[SubKeys.TRACKING], price: 4995, level: 0, type: SERVICE_TYPE.ADDON, lookupKey: SubKeys.TRACKING },
}
-/**
- * Get descriptive package name from lookup key
- * @param lookupKey - Package lookup key (e.g., 'ess_1', 'ent_2')
- * @returns Descriptive name (e.g., 'AgMission Essentials 1', 'AgMission Enterprise 2')
- */
-export function getPackageDisplayName(lookupKey: string): string {
- if (!lookupKey) {
- return '';
- }
-
- // Convert to lowercase to match SubKeys enum values (which are lowercase)
- const key = lookupKey.toLowerCase();
-
- // Check if key exists in subPlans
- if (subPlans[key]) {
- return subPlans[key].name;
- }
-
- // Fallback: Format the key (e.g., unknown_key -> Unknown Key)
- return lookupKey
- .replace(/_/g, ' ')
- .split(' ')
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
- .join(' ');
-}
-
-
export const SubErrMsgs = {}
// stripe errors
SubErrMsgs[SubStripe.REQUIRE_ACTION] = $localize`:@@subMsgReqAction:Please verify your card (#card#) for 3DS authentication. Click 'Submit' to confirm.`;
@@ -511,7 +338,7 @@ SubErrMsgs[SubAppErr.FETCH_SUB_PLANS_ERR] = $localize`:@@subMsgFetchSubPlansErr:
SubErrMsgs[SubAppErr.START_BIL_INFO_ERR] = $localize`:@@subMsgStartBilInfoErr:Initialization of Billing Information failed. Please reach out to support for assistance.`;
SubErrMsgs[SubAppErr.START_CHECKOUT_ERR] = $localize`:@@subMsgStartChkoutErr:Checkout process initialization failed. Please contact support for assistance.`;
SubErrMsgs[SubAppErr.CHECKOUT_TRIAL_ERR] = $localize`:@@subMsgChkoutTrialErr:Starting your trial subscription was unsuccessful. Please reach out to support for help.`;
-SubErrMsgs[SubAppErr.UPDATE_SUB_ERR] = $localize`:@@subMsgUpdateSubErr:Subscription update failed. Please contact support for assistance.`;
+SubErrMsgs[SubAppErr.UPDATE_SUB_ERR] = $localize`:@@subMsgUpdateSubErr:Subscription activation failed. Please contact support for assistance.`;
SubErrMsgs[SubAppErr.POLL_ERR] = $localize`:@@subMsgPollingErr:Monitoring your unpaid subscription was unsuccessful. Please reach out to support for assistance.`;
SubErrMsgs[SubAppErr.PAY_UNPAID_ERR] = $localize`:@@subMsgPayUnpaidErr:Payment for your outstanding subscription could not be processed. Please contact support for assistance.`;
SubErrMsgs[SubAppErr.PAY_UNPAID_CARD_ERR] = $localize`:@@subMsgPayUnpaidCardErr:Payment for your subscription using #card# was unsuccessful. Please choose a different card from your available payment options.`;
@@ -554,91 +381,6 @@ SubErrMsgs[SubAppErr.RES_ERR] = $localize`:@@subMsgResErr:Please contact support
SubErrMsgs[SubAppErr.APP_VENDOR_NOT_FOUND] = $localize`:@@subMsgAppVendorNotFound:Application vendor not found. Please contact support for assistance.`;
SubErrMsgs[SubAppErr.LOCAL_VENDOR_NOT_FOUND] = $localize`:@@subMsgLocalVendorNotFound:Local vendor not found. Please contact support for assistance.`;
-export const signupCode = Object.freeze({
- userExist: 'user_exist',
- signupFailed: 'signupFailed',
- invalidAccountErr: 'invalid_account',
- activeAccountErr: 'active_account',
- invalidTokenErr: 'invalid_token',
- unknownErr: 'unknown_error',
- signupLoadingError: 'signupLoadingError',
- emailError: 'email_error',
- verificationCodeExpired: 'verification_code_expired',
- invalidVerificationCode: 'invalid_verification_code',
- alreadySignedUp: 'already_signed_up',
-});
-
-export const signupMsg: any = Object.freeze({
- userExist: $localize`:@@userExist:User already exists`,
- unknownErr: $localize`:@@unknownErr:An unexpected error occurred during signup. Please try again later or contact support for assistance.`,
- activeAccountErr: $localize`:@@activeAccountErr:Account already active. Please login to continue.`,
- invalidAccountErr: $localize`:@@invalidAccountErr:Invalid account. Please check your contact email account and try again.`,
- invalidTokenErr: $localize`:@@invalidTokenErr:The verification link is invalid or expired. A new email has been sent to your registered address. Please check your inbox.`,
- signupLoadingError: $localize`:@@signupLoadingError:An error occurred while loading the signup page. Please try again later or contact support for assistance.`,
- emailMsg: $localize`:@@emailMsg:Your email helps us identify you and keep in touch about your account.`,
- reValidateMsg: $localize`:@@revalidateMsg:Your email address has been updated. Please verify your new email to keep your account secure.`,
- emailError: $localize`:@@emailError:Invalid email address. Please enter a valid email address and re-submit.`,
- verificationCodeExpired: $localize`:@@verificationCodeExpired:Your verification code has expired. Please re-verify your email address to receive a new code.`,
- invalidVerificationCode: $localize`:@@invalidVerificationCode:Invalid verification code. Please check your email and try again.`,
- alreadySignedUp: $localize`:@@alreadySignedUp:You have already signed up with #email#. Please login to continue or Verify with another email.`,
-})
-
-// ============================================================================
-// PARTNER INTEGRATION ERROR CODES & MESSAGES
-// ============================================================================
-
-/**
- * Partner integration error codes returned from backend API.
- * These codes are used to identify specific partner system errors.
- * Matches server-side constants in server/helpers/constants.js
- *
- * More error codes will be added as backend implementation expands.
- */
-export const partnerErrorCode = Object.freeze({
- // Current backend error codes (as of 2025-10-10)
- partnerServiceUnavailable: 'partner_service_unavailable',
- invalidAssignment: 'invalid_assignment',
- wrongCredential: 'wrong_credential',
-
- // Default fallback for unmapped errors
- unknownError: 'unknown_error'
-});
-
-/**
- * User-facing error messages for partner integration errors.
- * Mapped to partner error codes for consistent UX.
- * Matches server-side error codes in server/helpers/constants.js
- */
-export const partnerErrorMsg: any = Object.freeze({
- // Current backend error messages (as of 2025-10-10)
- partnerServiceUnavailable: $localize`:@@partnerServiceUnavailable:Partner system is currently unavailable. Please try again later.`,
- invalidAssignment: $localize`:@@partnerInvalidAssignment:Invalid partner assignment. Please verify your configuration.`,
- wrongCredential: $localize`:@@partnerWrongCredential:Invalid username or password. Please check your credentials and try again.`,
-
- // Default fallback message
- unknownError: $localize`:@@partnerUnknownError:An unexpected error occurred with partner system. Please contact support for assistance.`
-});
-
-/**
- * Generic error handler for user and subscription-related operations.
- *
- * This function processes errors from API calls and transforms them into appropriate
- * typed responses based on the error context. It delegates error handling to the
- * middleware function `handleSubErr` which will identify the error type and return
- * the appropriate response (typically an Observable with an error action).
- *
- * @template T - The return type of the error handler (usually an Observable with an error action)
- * @param {any} err - The error object to process, typically from an HTTP request
- * @returns {T} A typed response containing the appropriate error action based on the error context
- *
- * @example
- * // Handle error in an effect
- * catchError(err => handleErr>(err))
- *
- * @example
- * // Handle error with specific error type
- * catchError(err => handleErr>(err))
- */
export const handleErr = (err): T => {
return errorHandler(err, handleSubErr);
}
@@ -647,14 +389,10 @@ export const handleSubErr = (params: { error, opt?}) => {
if (params?.error instanceof HttpErrorResponse) {
const tag = params?.error?.error?.['.tag'] || params?.error?.error?.error?.['.tag'];
if (tag) {
- if (params?.opt?.extra == signupCode.signupFailed) {
- return handleSignupErr({ error: params.error, opt: { ...params.opt, tag } });
- }
if (hasVendorErr(tag)) {
return handleVendorErr({ error: params.error, opt: { ...params.opt, tag } });
}
- // Preserve custom message if provided, only use tag as fallback
- return handleAppErr({ error: params.error, opt: { ...params.opt, msg: params?.opt?.msg || tag } });
+ return handleAppErr({ error: params.error, opt: { ...params.opt, msg: tag } });
} else {
return handleAppErr(params);
}
@@ -663,90 +401,6 @@ export const handleSubErr = (params: { error, opt?}) => {
}
}
-export const handleSignupErr = (params: { error, opt?}) => {
- const tag = params?.opt?.tag;
- switch (tag) {
- case signupCode.userExist:
- return { code: signupCode.userExist, message: signupMsg.userExist };
- case signupCode.activeAccountErr:
- return { code: signupCode.activeAccountErr, message: signupMsg.activeAccountErr };
- case signupCode.invalidAccountErr:
- return { code: signupCode.invalidAccountErr, message: signupMsg.invalidAccountErr };
- case signupCode.invalidTokenErr:
- return { code: signupCode.invalidTokenErr, message: signupMsg.invalidTokenErr };
- case signupCode.signupLoadingError:
- return { code: signupCode.signupLoadingError, message: signupMsg.signupLoadingError };
- case signupCode.emailError:
- return { code: signupCode.emailError, message: signupMsg.emailError };
- case signupCode.verificationCodeExpired:
- return { code: signupCode.verificationCodeExpired, message: signupMsg.verificationCodeExpired };
- case signupCode.invalidVerificationCode:
- return { code: signupCode.invalidVerificationCode, message: signupMsg.invalidVerificationCode };
- case signupCode.alreadySignedUp:
- return { code: signupCode.alreadySignedUp, message: signupMsg.alreadySignedUp.replace('#email#', params?.opt?.email || '') };
- default:
- return { code: signupCode.unknownErr, message: signupMsg.unknownErr };
- }
-}
-
-/**
- * Partner integration error handler (standalone function).
- * Can be used directly in components or through the middleware chain.
- * Extracts .tag from error response and maps to user-facing error messages.
- *
- * @param {any} error - HttpErrorResponse or error object from API
- * @returns {object} Object with code and message properties
- *
- * @example
- * // Direct usage in components
- * catchError(err => {
- * const errorResult = handlePartnerErr(err);
- * this.satlocError = errorResult.message;
- * return of(null);
- * })
- *
- * @example
- * // Backend returns: { error: { '.tag': 'invalid_credentials' } }
- * // Returns: { code: 'invalid_credentials', message: 'Invalid username or password...' }
- */
-export const handlePartnerErr = (error: any): { code: string, message: string } => {
- // Extract .tag from error response (supports multiple nesting patterns)
- // Backend structure: HttpErrorResponse.error = { error: { ".tag": "...", "message": "..." } }
- let tag: string | null = null;
-
- if (error instanceof HttpErrorResponse) {
- // Try deeper nesting first (error.error.error['.tag']), then fallback to error.error['.tag']
- tag = error?.error?.error?.['.tag'] || error?.error?.['.tag'];
- } else if (error?.error) {
- // For non-HttpErrorResponse objects (e.g., success response with error property)
- tag = error?.error?.error?.['.tag'] || error?.error?.['.tag'];
- }
-
- // Map tag to error code and message
- // More cases will be added as backend implementation expands
- switch (tag) {
- // Current backend error codes (as of 2025-10-10)
- case partnerErrorCode.partnerServiceUnavailable:
- return { code: partnerErrorCode.partnerServiceUnavailable, message: partnerErrorMsg.partnerServiceUnavailable };
- case partnerErrorCode.invalidAssignment:
- return { code: partnerErrorCode.invalidAssignment, message: partnerErrorMsg.invalidAssignment };
- case partnerErrorCode.wrongCredential:
- return { code: partnerErrorCode.wrongCredential, message: partnerErrorMsg.wrongCredential };
-
- // Default fallback for unmapped errors
- default:
- return { code: partnerErrorCode.unknownError, message: partnerErrorMsg.unknownError };
- }
-}
-
-/**
- * Check if error tag is a partner integration error.
- * Used by middleware chain to route to handlePartnerErr.
- */
-export const hasPartnerErr = (tag: string): boolean => {
- return Object.values(partnerErrorCode).includes(tag as any);
-}
-
const handleVendorErr = (params: { error, opt?}) => {
const tag = params?.opt?.tag == SubAppErr.APP_VENDOR_NOT_FOUND ? SubAppErr.APP_VENDOR_NOT_FOUND : SubAppErr.LOCAL_VENDOR_NOT_FOUND;
const code = params?.opt?.extra;
@@ -810,7 +464,6 @@ export const hasVendorErr = (code): boolean => {
}
const handleAppErr = (params: { error, opt?}) => {
- // Handle card-specific errors first (unpaid and decline errors)
const isStripeCardErr = !!params?.opt?.card;
if (isStripeCardErr) {
const declineErr = params?.error?.error?.decline_code || params?.error?.error?.code;
@@ -820,89 +473,87 @@ const handleAppErr = (params: { error, opt?}) => {
} else if (declineErr) {
return of(new UpdateSubscriptionStatus({ code: SubStripe.CARD_DECLINED, message: SubErrMsgs[declineErr]?.replace('#card#', `${params?.opt?.card?.brand} ${SubTexts.ending} **** ${params?.opt?.card?.last4}`) || SubErrMsgs[SubStripe.CARD_DECLINED]?.replace('#card#', `${params?.opt?.card?.brand} ${SubTexts.ending} **** ${params?.opt?.card?.last4}`) || '' }));
}
- // No decline error - fall through to errTag handling below
- }
-
- // Handle errors by errTag (applies to both card and non-card errors)
- const errTag = params?.opt?.extra;
-
- // subscription intent error status
- if (errTag == SubAppErr.CRT_PM_ERR || SubAppErr.CHECKOUT_TRIAL_ERR) {
- const declineErr = params?.error?.error?.decline_code || params?.error?.error?.code;
- if (errTag == SubAppErr.CRT_PM_ERR) {
- return of(new CreatePaymentMethodFailed({ code: SubAppErr.CRT_PM_ERR, message: SubErrMsgs[declineErr]?.replace('#card#', SubTexts.card) || params?.opt?.msg || SubErrMsgs[SubAppErr.CRT_PM_ERR] }));
- } else if (errTag == SubAppErr.CHECKOUT_TRIAL_ERR) {
- return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CHECKOUT_TRIAL_ERR, message: SubErrMsgs[declineErr]?.replace('#card#', SubTexts.card) || params?.opt?.msg || SubErrMsgs[SubAppErr.CHECKOUT_TRIAL_ERR] }));
- }
- }
-
- switch (errTag) {
+ } else {
+ const errTag = params?.opt?.extra;
// subscription intent error status
- case SubAppErr.START_BIL_INFO_ERR:
- return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.START_BIL_INFO_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.START_BIL_INFO_ERR] }));
- case SubAppErr.START_CHECKOUT_ERR:
- return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.START_CHECKOUT_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.START_CHECKOUT_ERR] }));
- case SubAppErr.CHECKOUT_CONT_TRIAL_ERR:
- return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CHECKOUT_CONT_TRIAL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CHECKOUT_CONT_TRIAL_ERR] }));
- case SubAppErr.CANCEL_ERR:
- return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CANCEL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CANCEL_ERR] }));
- case SubAppErr.REFUND_ERR:
- return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.REFUND_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.REFUND_ERR] }));
- case SubAppErr.RES_UNPAID_ERR:
- return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.RES_UNPAID_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.RES_UNPAID_ERR] }));
- case SubAppErr.COMP_ACT_ERR:
- return of(new UpdateSubscriptionIntentStatus({ code: SubAppErr.COMP_ACT_ERR, message: SubErrMsgs[SubAppErr.COMP_ACT_ERR] }));
- case SubAppErr.STRIPE_ERR:
- return of(new UpdateSubscriptionIntentStatus({ code: SubAppErr.STRIPE_ERR, message: SubErrMsgs[SubAppErr.STRIPE_ERR] }));
- case SubAppErr.LOAD_STRIPE_ERR:
- return of(new LoadStripeFailed({ code: SubAppErr.LOAD_STRIPE_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.LOAD_STRIPE_ERR] }));
- case SubAppErr.APP_DISCOUNT_PREVIEW_ERR:
- return of(new ApplyDiscountPreviewFailed({ code: SubAppErr.APP_DISCOUNT_PREVIEW_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.APP_DISCOUNT_PREVIEW_ERR] }));
+ if (errTag == SubAppErr.CRT_PM_ERR || SubAppErr.CHECKOUT_TRIAL_ERR) {
+ const declineErr = params?.error?.error?.decline_code || params?.error?.error?.code;
+ if (errTag == SubAppErr.CRT_PM_ERR) {
+ return of(new CreatePaymentMethodFailed({ code: SubAppErr.CRT_PM_ERR, message: SubErrMsgs[declineErr]?.replace('#card#', SubTexts.card) || params?.opt?.msg || SubErrMsgs[SubAppErr.CRT_PM_ERR] }));
+ } else if (errTag == SubAppErr.CHECKOUT_TRIAL_ERR) {
+ return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CHECKOUT_TRIAL_ERR, message: SubErrMsgs[declineErr]?.replace('#card#', SubTexts.card) || params?.opt?.msg || SubErrMsgs[SubAppErr.CHECKOUT_TRIAL_ERR] }));
+ }
+ }
- // subscription error status
- case SubAppErr.REFRESH_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.REFRESH_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.REFRESH_ERR] }));
- case SubAppErr.UPDATE_SUB_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.UPDATE_SUB_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.UPDATE_SUB_ERR] }));
- case SubAppErr.FETCH_SUB_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.FETCH_SUB_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_SUB_ERR] }));
- case SubAppErr.POLL_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.POLL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.POLL_ERR] }));
- case SubAppErr.CONF_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.CONF_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CONF_ERR] }));
- case SubAppErr.PAY_UNPAID_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.PAY_UNPAID_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.PAY_UNPAID_ERR] }));
- case SubAppErr.NO_INVOICES_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_INVOICES_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_INVOICES_ERR] }));
- case SubAppErr.NO_SUBS_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_SUBS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_SUBS_ERR] }));
- case SubAppErr.NO_ACTIONS_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_ACTIONS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_ACTIONS_ERR] }));
- case SubAppErr.ADD_PM_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.ADD_PM_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.ADD_PM_ERR] }));
- case SubAppErr.EDIT_PM_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.EDIT_PM_ERR, message: SubErrMsgs[SubAppErr.EDIT_PM_ERR] }));
- case SubAppErr.DELETE_PM_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.DELETE_PM_ERR, message: SubErrMsgs[SubAppErr.DELETE_PM_ERR] }));
- case SubAppErr.CHANGE_PM_ERR:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr.CHANGE_PM_ERR, message: SubErrMsgs[SubAppErr.CHANGE_PM_ERR] }));
+ switch (errTag) {
- // payment method error status
- case SubAppErr.FETCH_PMT_ERR:
- return of(new FetchError({ code: SubAppErr.FETCH_PMT_ERR, message: SubErrMsgs[SubAppErr.FETCH_PMT_ERR] }));
+ // subscription intent error status
+ case SubAppErr.START_BIL_INFO_ERR:
+ return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.START_BIL_INFO_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.START_BIL_INFO_ERR] }));
+ case SubAppErr.START_CHECKOUT_ERR:
+ return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.START_CHECKOUT_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.START_CHECKOUT_ERR] }));
+ case SubAppErr.CHECKOUT_CONT_TRIAL_ERR:
+ return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CHECKOUT_CONT_TRIAL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CHECKOUT_CONT_TRIAL_ERR] }));
+ case SubAppErr.CANCEL_ERR:
+ return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.CANCEL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CANCEL_ERR] }));
+ case SubAppErr.REFUND_ERR:
+ return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.REFUND_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.REFUND_ERR] }));
+ case SubAppErr.RES_UNPAID_ERR:
+ return of(new CreateSubscriptionIntentFailed({ code: SubAppErr.RES_UNPAID_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.RES_UNPAID_ERR] }));
+ case SubAppErr.COMP_ACT_ERR:
+ return of(new UpdateSubscriptionIntentStatus({ code: SubAppErr.COMP_ACT_ERR, message: SubErrMsgs[SubAppErr.COMP_ACT_ERR] }));
+ case SubAppErr.STRIPE_ERR:
+ return of(new UpdateSubscriptionIntentStatus({ code: SubAppErr.STRIPE_ERR, message: SubErrMsgs[SubAppErr.STRIPE_ERR] }));
+ case SubAppErr.LOAD_STRIPE_ERR:
+ return of(new LoadStripeFailed({ code: SubAppErr.LOAD_STRIPE_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.LOAD_STRIPE_ERR] }));
+ case SubAppErr.APP_DISCOUNT_PREVIEW_ERR:
+ return of(new ApplyDiscountPreviewFailed({ code: SubAppErr.APP_DISCOUNT_PREVIEW_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.APP_DISCOUNT_PREVIEW_ERR] }));
- // usage error status
- case SubAppErr.FETCH_USAGE_ERR:
- return of(new FetchUsageFailed({ code: SubAppErr.FETCH_USAGE_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_USAGE_ERR] }));
+ // subscription error status
+ case SubAppErr.REFRESH_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.REFRESH_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.REFRESH_ERR] }));
+ case SubAppErr.UPDATE_SUB_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.UPDATE_SUB_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.UPDATE_SUB_ERR] }));
+ case SubAppErr.FETCH_SUB_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.FETCH_SUB_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_SUB_ERR] }));
+ case SubAppErr.POLL_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.POLL_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.POLL_ERR] }));
+ case SubAppErr.CONF_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.CONF_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.CONF_ERR] }));
+ case SubAppErr.PAY_UNPAID_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.PAY_UNPAID_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.PAY_UNPAID_ERR] }));
+ case SubAppErr.NO_INVOICES_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_INVOICES_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_INVOICES_ERR] }));
+ case SubAppErr.NO_SUBS_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_SUBS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_SUBS_ERR] }));
+ case SubAppErr.NO_ACTIONS_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.NO_ACTIONS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.NO_ACTIONS_ERR] }));
+ case SubAppErr.ADD_PM_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.ADD_PM_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.ADD_PM_ERR] }));
+ case SubAppErr.EDIT_PM_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.EDIT_PM_ERR, message: SubErrMsgs[SubAppErr.EDIT_PM_ERR] }));
+ case SubAppErr.DELETE_PM_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.DELETE_PM_ERR, message: SubErrMsgs[SubAppErr.DELETE_PM_ERR] }));
+ case SubAppErr.CHANGE_PM_ERR:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr.CHANGE_PM_ERR, message: SubErrMsgs[SubAppErr.CHANGE_PM_ERR] }));
- // subplan error status
- case SubAppErr.FETCH_SUB_PLANS_ERR:
- return of(new FetchSubPlansFailed({ code: SubAppErr.FETCH_SUB_PLANS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_SUB_PLANS_ERR] }));
+ // payment method error status
+ case SubAppErr.FETCH_PMT_ERR:
+ return of(new FetchError({ code: SubAppErr.FETCH_PMT_ERR, message: SubErrMsgs[SubAppErr.FETCH_PMT_ERR] }));
- // default error status
- default:
- return of(new UpdateSubscriptionStatus({ code: SubAppErr._500_ERR, message: SubErrMsgs[SubAppErr._500_ERR] }));
+ // usage error status
+ case SubAppErr.FETCH_USAGE_ERR:
+ return of(new FetchUsageFailed({ code: SubAppErr.FETCH_USAGE_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_USAGE_ERR] }));
+
+ // subplan error status
+ case SubAppErr.FETCH_SUB_PLANS_ERR:
+ return of(new FetchSubPlansFailed({ code: SubAppErr.FETCH_SUB_PLANS_ERR, message: params?.opt?.msg || SubErrMsgs[SubAppErr.FETCH_SUB_PLANS_ERR] }));
+
+ // default error status
+ default:
+ return of(new UpdateSubscriptionStatus({ code: SubAppErr._500_ERR, message: SubErrMsgs[SubAppErr._500_ERR] }));
+ }
}
}
diff --git a/Development/client/src/app/profile/coupon/coupon.component.html b/Development/client/src/app/profile/coupon/coupon.component.html
index aff5e18..76061e7 100644
--- a/Development/client/src/app/profile/coupon/coupon.component.html
+++ b/Development/client/src/app/profile/coupon/coupon.component.html
@@ -1,19 +1,14 @@
-
Coupon
+
Coupon
-
-
- {{getCouponName(coupon)}}
-
+
+ {{code}}
+
-
+
\ No newline at end of file
diff --git a/Development/client/src/app/profile/coupon/coupon.component.ts b/Development/client/src/app/profile/coupon/coupon.component.ts
index e4fbcef..7373942 100644
--- a/Development/client/src/app/profile/coupon/coupon.component.ts
+++ b/Development/client/src/app/profile/coupon/coupon.component.ts
@@ -1,51 +1,22 @@
-import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges } from '@angular/core';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
import { SubTexts } from '../common';
-// Coupon interface to display name instead of ID
-interface CouponDisplay {
- id: string;
- name: string;
-}
-
@Component({
selector: 'coupon',
templateUrl: './coupon.component.html',
styleUrls: ['./coupon.component.css']
})
-export class CouponComponent implements OnChanges {
+export class CouponComponent {
readonly SubTexts = SubTexts;
- @Input() coupons: string[] | CouponDisplay[];
+ @Input() coupons: string[];
@Input() error: string;
@Output() appCoupEvt = new EventEmitter();
@Output() remvCoupEvt = new EventEmitter();
couponCode: string;
- private previousCouponCount = 0;
-
- ngOnChanges(changes: SimpleChanges): void {
- // Clear input when a coupon is successfully added
- if (changes['coupons'] && this.coupons) {
- const currentCount = this.coupons.length;
- // If count increased, a coupon was successfully added
- if (currentCount > this.previousCouponCount) {
- this.couponCode = '';
- }
- this.previousCouponCount = currentCount;
- }
- }
-
- // Helper to get coupon ID (supports both string[] and CouponDisplay[])
- getCouponId(coupon: string | CouponDisplay): string {
- return typeof coupon === 'string' ? coupon : coupon.id;
- }
-
- // Helper to get coupon display name
- getCouponName(coupon: string | CouponDisplay): string {
- return typeof coupon === 'string' ? coupon : (coupon.name || coupon.id);
- }
get cantApply() {
- return this.coupons?.some((coupon) => this.getCouponId(coupon) === this.couponCode);
+ return this.coupons?.some((coupon) => coupon === this.couponCode);
}
applyCoupon() {
@@ -55,17 +26,11 @@ export class CouponComponent implements OnChanges {
}
}
- removeCoupon(coupon: string | CouponDisplay) {
- const codeToRemove = this.getCouponId(coupon);
- if (this.coupons) {
- const filtered = (this.coupons as any[]).filter(c => this.getCouponId(c) !== codeToRemove);
- this.coupons = filtered as string[] | CouponDisplay[];
- }
- // Update previousCouponCount since we locally modified the array
- this.previousCouponCount = this.coupons?.length || 0;
+ removeCoupon(code: string) {
+ this.coupons = this.coupons?.filter(coupon => coupon !== code) || [];
this.couponCode = '';
this.error = '';
- this.remvCoupEvt.emit(codeToRemove);
+ this.remvCoupEvt.emit(code);
}
onChange() {
diff --git a/Development/client/src/app/profile/effects/usage.effects.ts b/Development/client/src/app/profile/effects/usage.effects.ts
index c961c3d..dddf96c 100644
--- a/Development/client/src/app/profile/effects/usage.effects.ts
+++ b/Development/client/src/app/profile/effects/usage.effects.ts
@@ -37,20 +37,10 @@ export class UsageEffects {
repeat()
);
- private calcUsageDetail(
- usage: Usage,
- lookupKey: string,
- periods: { periodStart: number, periodEnd: number },
- billPeriods?: BillPeriod[],
- effectiveMaxAcres?: number | null
- ): usageAction.FetchUsageSuccess {
+ private calcUsageDetail(usage: Usage, lookupKey: string, periods: { periodStart: number, periodEnd: number }, billPeriods?: BillPeriod[]): usageAction.FetchUsageSuccess {
const pkg = subPlans[lookupKey];
if (pkg) {
- // Use MongoDB-prioritized maxAcres if provided, otherwise fall back to static subPlans
- const pkgMaxAcre = effectiveMaxAcres !== undefined
- ? effectiveMaxAcres
- : pkg.maxAcres;
-
+ const pkgMaxAcre = pkg.maxAcres;
const periodStart = periods.periodStart;
const periodEnd = periods.periodEnd;
const ttArea = UnitUtils.haToArea(Number(usage.ttArea), true);
@@ -79,26 +69,16 @@ export class UsageEffects {
private fetchUsageForSpecificPeriod(action: usageAction.FetchUsage): Observable {
const periodStart = action.payload.period.fromTS;
const periodEnd = action.payload.period.toTS;
- const effectiveMaxAcres = action.payload.effectiveMaxAcres;
-
return this.subSvc.retrieveUsage({
byPuid: action.payload.byPuid,
fromTS: periodStart,
toTS: periodEnd
}).pipe(
- map((usage: Usage) => this.calcUsageDetail(
- usage,
- action.payload.lookupKey,
- { periodStart, periodEnd },
- undefined,
- effectiveMaxAcres
- ))
+ map((usage: Usage) => this.calcUsageDetail(usage, action.payload.lookupKey, { periodStart, periodEnd }))
);
}
private fetchUsageForLatestPeriod(action: usageAction.FetchUsage): Observable {
- const effectiveMaxAcres = action.payload.effectiveMaxAcres;
-
return this.subSvc.retrieveBilPeriod(action.payload.custId).pipe(
switchMap((_billPeriods: BillPeriod[]) => {
if (!_billPeriods || !_billPeriods.length) return of(new usageAction.FetchUsageSuccess(void 0));
@@ -118,13 +98,7 @@ export class UsageEffects {
fromTS: periodStart,
toTS: periodEnd
}).pipe(
- map((usage: Usage) => this.calcUsageDetail(
- usage,
- latestPeriod.lookupKey,
- { periodStart, periodEnd },
- _billPeriods,
- effectiveMaxAcres
- ))
+ map((usage: Usage) => this.calcUsageDetail(usage, action.payload.lookupKey, { periodStart, periodEnd }, _billPeriods))
);
})
);
diff --git a/Development/client/src/app/profile/manage-services/manage-services.component.css b/Development/client/src/app/profile/manage-services/manage-services.component.css
index 0b35752..930b003 100644
--- a/Development/client/src/app/profile/manage-services/manage-services.component.css
+++ b/Development/client/src/app/profile/manage-services/manage-services.component.css
@@ -1,412 +1,3 @@
.price {
font-weight: bold;
-}
-
-/* ============================================================================
- * PROMO BANNER STYLES (Using agm-constraint-message)
- * ============================================================================ */
-
-/* Override constraint-message defaults for promo banners in this context */
-::ng-deep agm-constraint-message.promo-banner-packages .agm-constraint-message,
-::ng-deep agm-constraint-message.promo-banner-addons .agm-constraint-message {
- margin-top: 0;
- margin-bottom: 8px;
- max-width: 100%;
-}
-
-/* Override constraint-message icon with Material Icons local_offer */
-::ng-deep agm-constraint-message .agm-constraint-icon.ui-icon-local-offer {
- font-family: 'Material Icons';
- font-weight: normal;
- font-style: normal;
- font-size: 1.25rem;
- display: inline-block;
- line-height: 1;
- text-transform: none;
- letter-spacing: normal;
- word-wrap: normal;
- white-space: nowrap;
- direction: ltr;
- -webkit-font-smoothing: antialiased;
- text-rendering: optimizeLegibility;
- -moz-osx-font-smoothing: grayscale;
- font-feature-settings: 'liga';
-}
-
-::ng-deep agm-constraint-message .agm-constraint-icon.ui-icon-local-offer::before {
- content: "local_offer";
-}
-
-/* ============================================================================
- * PRICE CELL STYLES WITH PROMO (Option A - Inline Strikethrough)
- * ============================================================================ */
-
-/* Price cell container */
-.price-cell {
- font-weight: bold;
-}
-
-/* Regular price (no promo) */
-.regular-price {
- color: #212121;
-}
-
-/* Price content wrapper for promo display */
-.price-content {
- display: inline-flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: center;
- /* Center content when it wraps to multiple lines */
- gap: 4px;
-}
-
-/* Original price with strikethrough */
-.original-price {
- text-decoration: line-through;
- color: #757575;
- font-weight: normal;
- font-size: 0.9em;
- white-space: nowrap;
-}
-
-/* Arrow between prices */
-.price-arrow {
- margin: 0 4px;
- color: #4CAF50;
- font-weight: bold;
-}
-
-/* New promo price */
-.promo-price {
- color: #2E7D32;
- font-weight: bold;
- white-space: nowrap;
-}
-
-/* Promo line wrapper - keeps price and badge together */
-.promo-line {
- display: inline-flex;
- align-items: center;
-}
-
-/* ============================================================================
- * PROMO NAME IN NAME COLUMN (Name only, no valid until date)
- * Shows promo name below package/addon name in Name column
- * Valid until date moved to price column below prices
- * ============================================================================ */
-
-/* Package promo name only - Dual-mode styling: Blue for active, green for available */
-.package-promo-name-only {
- margin-top: 4px;
-}
-
-/* Promo name with emoji - Available promo (green) */
-.package-promo-name-only.available-promo .promo-name {
- font-size: 0.85em;
- color: #2E7D32;
- /* AgMission primary dark green */
- font-weight: 500;
-}
-
-/* Addon promo name only - Dual-mode styling: Blue for active, green for available */
-.addon-promo-name-only {
- margin-top: 4px;
-}
-
-/* Promo name with emoji - Available promo (green) */
-.addon-promo-name-only.available-promo .promo-name {
- font-size: 0.85em;
- color: #2E7D32;
- /* AgMission primary dark green */
- font-weight: 500;
-}
-
-/* ============================================================================
- * PRICE WITH PROMO - VALID UNTIL DATE BELOW PRICES
- * Shows prices with valid until date below in price column
- * ============================================================================ */
-
-/* Price with promo container - vertical layout */
-.price-with-promo {
- display: flex;
- flex-direction: column;
- gap: 8px;
- align-items: center;
- /* Center all content horizontally */
-}
-
-/* Promo validity date below prices - secondary text */
-.promo-validity {
- font-size: 0.75em;
- color: #757575;
- /* AgMission secondary text color */
- font-weight: normal;
- font-style: italic;
- margin-top: 4px;
-}
-
-/* Name cell styling for proper vertical layout */
-.package-name-cell,
-.addon-name-cell {
- vertical-align: top;
-}
-
-/* Caption with subtitle container - Package table */
-::ng-deep .prices-table .caption-with-subtitle {
- display: flex;
- flex-direction: row;
- align-items: baseline;
- gap: 0.5rem;
- /* 8px horizontal spacing between caption and duration */
-}
-
-/* Caption with subtitle container - Addon table */
-::ng-deep .addons-table .caption-with-subtitle {
- display: flex;
- flex-direction: row;
- align-items: baseline;
- gap: 0.5rem;
- /* 8px horizontal spacing between caption and duration */
-}
-
-/* Main caption styling (preserve existing styles if any) */
-.main-caption {
- font-weight: bold;
-}
-
-/* Duration subtitle styling */
-.duration-subtitle {
- font-size: 0.85em;
- font-style: italic;
- color: #757575;
- /* AgMission secondary text color */
- font-weight: normal;
- letter-spacing: 0.25px;
-}
-
-/* Addon table subtitle - white text for green background (WCAG compliance) */
-::ng-deep .addons-table .duration-subtitle {
- color: #ffffff;
- /* White text for sufficient contrast on green background (21:1 ratio) */
-}
-
-/* Mobile responsiveness */
-@media (max-width: 768px) {
- ::ng-deep .prices-table .caption-with-subtitle {
- gap: 0.375rem;
- /* 6px horizontal spacing on mobile for package table */
- }
-
- ::ng-deep .addons-table .caption-with-subtitle {
- gap: 0.375rem;
- /* 6px horizontal spacing on mobile for addon table */
- }
-
- .duration-subtitle {
- font-size: 0.8em;
- }
-
- /*
- * Two-Line Stacked Layout for Mobile:
- * Line 1: Strikethrough original price (smaller, muted)
- * Line 2: Promo price + badge (prominent)
- * This keeps full context while fitting narrower columns
- */
- .price-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- /* Center content on mobile */
- gap: 4px;
- }
-
- /* Hide arrow on mobile - stacked layout replaces it */
- .price-arrow {
- display: none;
- }
-
- /* Original price - smaller and muted on line 1 */
- .original-price {
- font-size: 0.8em;
- color: #757575;
- /* textSecondaryColor - de-emphasized */
- }
-
- /* Promo line wrapper - keeps price + badge together on line 2 */
- .promo-line {
- display: flex;
- align-items: center;
- flex-wrap: nowrap;
- }
-
- /* Promo price + label wrapper - stays on line 2 */
- .promo-price {
- font-weight: bold;
- }
-
- /* Promo label inline with new price on line 2 */
- .promo-label {
- margin-left: 6px;
- font-size: 0.7em;
- padding: 2px 6px;
- }
-}
-
-/* Extra small screens - tighter spacing */
-@media (max-width: 480px) {
- .original-price {
- font-size: 0.75em;
- }
-
- .promo-label {
- font-size: 0.65em;
- padding: 2px 4px;
- }
-}
-
-/* ============================================================================
- * PROMO EXPIRY COUNTDOWN STYLES (r948+ promoDetails)
- * ============================================================================ */
-
-/* Promo expiry information for time-limited promos */
-.promo-expiry-info {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- margin-left: 8px;
- padding: 2px 6px;
- background: #FFF3E0;
- /* Light amber background */
- border-left: 3px solid #FF9800;
- /* AgMission warning orange */
- border-radius: 3px;
- color: #E65100;
- /* Dark orange text */
- font-size: 0.8125rem;
-}
-
-.promo-expiry-info .pi {
- font-size: 0.75rem;
-}
-
-/* Responsive adjustments for promo expiry display */
-@media (max-width: 768px) {
- .promo-expiry-info {
- display: block;
- margin-left: 0;
- margin-top: 4px;
- font-size: 0.75rem;
- }
-}
-
-/* ============================================================================
- * LEGACY ESS_1 AND ESS_1_1 UPGRADE STYLES (Option A)
- * ============================================================================ */
-
-/* Legacy badge for ESS_1 - DEPRECATED: Badge removed per user request */
-/* Discontinuation notice now uses agm-legacy-notice-label component */
-/* See: /client/src/app/shared/legacy-notice-label/ */
-
-/* ============================================================================
- * SKELETON LOADER STYLES (Task 04 - Eliminate Double Render)
- * ============================================================================ */
-
-.skeleton-loader {
- padding: 1.5rem;
- background: #ffffff;
-}
-
-.skeleton-header {
- margin-bottom: 1.5rem;
-}
-
-.skeleton-title {
- width: 60%;
- height: 28px;
- background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
- background-size: 200% 100%;
- animation: shimmer 1.5s infinite;
- border-radius: 4px;
-}
-
-.skeleton-table {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.skeleton-row {
- display: flex;
- gap: 12px;
- padding: 16px;
- background: #f9f9f9;
- border-radius: 4px;
-}
-
-.skeleton-cell {
- height: 20px;
- background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
- background-size: 200% 100%;
- animation: shimmer 1.5s infinite;
- border-radius: 4px;
-}
-
-.skeleton-name {
- flex: 4;
-}
-
-.skeleton-vehicles {
- flex: 1.5;
-}
-
-.skeleton-acres {
- flex: 1.5;
-}
-
-.skeleton-price {
- flex: 3;
-}
-
-.skeleton-loading-text {
- text-align: center;
- color: #757575;
- font-size: 14px;
- margin-top: 1.5rem;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 8px;
-}
-
-.skeleton-loading-text i {
- font-size: 1.2rem;
- color: #4CAF50;
-}
-
-@keyframes shimmer {
- 0% {
- background-position: -200% 0;
- }
-
- 100% {
- background-position: 200% 0;
- }
-}
-
-/* Responsive skeleton on mobile */
-@media (max-width: 768px) {
- .skeleton-row {
- flex-direction: column;
- gap: 8px;
- }
-
- .skeleton-cell {
- width: 100%;
- }
-}
-
-/* Minimum width for payment summary cards to prevent layout collapse */
-.card.in-card-pad {
- min-width: 300px;
-}
+}
\ No newline at end of file
diff --git a/Development/client/src/app/profile/manage-services/manage-services.component.html b/Development/client/src/app/profile/manage-services/manage-services.component.html
index f2f328e..2134ae7 100644
--- a/Development/client/src/app/profile/manage-services/manage-services.component.html
+++ b/Development/client/src/app/profile/manage-services/manage-services.component.html
@@ -1,41 +1,17 @@
-
+
-
-
-
-
-
-
-
-
- {{ Labels.LOADING_SUBSCRIPTION_DATA }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{status?.message}}
-
-
+
+
+
+
+
+
+
+ {{status?.message}}
+
@@ -45,72 +21,21 @@
-
-
-
-
+
-
- AgMission Essentials
- 0">
- / {{ getDurationLabel(essPkgs[0].interval ? essPkgs[0].interval : '') }}
-
-
+ AgMission Essentials
- {{col.header}}
+ {{col.header}}
-
-
-
-
-
- {{item.name}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{formatVehiclesDisplay(item.maxVehicles)}}
+
+ {{item.name}}
+ {{item.Vehicles}}
{{convMaxAcre(item.maxAcres)}}
-
-
-
-
-
-
- {{item.price | usCurrency}} US
-
-
+ {{item.price | usCurrency}} US
@@ -118,17 +43,10 @@
-
-
-
+
+
-
- Addons
- 0">
- / {{ getDurationLabel(addons[0].interval ? addons[0].interval : '') }}
-
-
+ Addons
@@ -137,67 +55,19 @@
-
-
- {{item.name}}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{item.price | usCurrency}}
- →
- {{calculatePromoPrice(item.price, promo) | usCurrency}} US
-
-
-
- {{item.price | usCurrency}} US
-
-
+ {{item.name}}
+ {{item.price | usCurrency}} US
-
+
- {{$any(addonQuan)[item.lookupKey]}}
+ {{addonQuan[item.lookupKey]}}
-
-
-
-
-
-
- {{calAddonTotal(item) | usCurrency}} US
-
-
+ {{calAddonTotal(item) | usCurrency}} US
@@ -206,12 +76,10 @@
@@ -221,9 +89,7 @@
diff --git a/Development/client/src/app/profile/manage-services/manage-services.component.spec.ts b/Development/client/src/app/profile/manage-services/manage-services.component.spec.ts
new file mode 100644
index 0000000..84c1208
--- /dev/null
+++ b/Development/client/src/app/profile/manage-services/manage-services.component.spec.ts
@@ -0,0 +1,217 @@
+import { DebugElement } from '@angular/core';
+import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import * as fromStore from '../../reducers/index';
+import { TableModule } from 'primeng/table';
+import { ButtonModule } from 'primeng/button';
+import { ManageServicesComponent } from './manage-services.component';
+import {
+ Package,
+ Addon,
+ DisplayPackage
+} from '@app/domain/models/subscription.model';
+import { UserModel } from '@app/auth/models/user.model';
+import {
+ getEssPkgs,
+ getEntPkgs,
+ getAddons,
+ getSelectedPkg,
+ getSelectedAddons
+} from '../selectors/profile.selector'
+import { ActivatedRoute } from '@angular/router';
+
+describe('ManageServicesComponent', () => {
+ let component: ManageServicesComponent;
+ let fixture: ComponentFixture
;
+ let debugElement: DebugElement;
+ let store : MockStore;
+ 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'
+ }]
+ },
+ name: 'bill'
+ };
+ const displayPackage: DisplayPackage = {
+ essential: [
+ { priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: 995, lookupKey: 'ess_1'},
+ { priceId: '2', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'contact', lookupKey: ''}
+ ],
+ enterprise: [
+ { priceId: '6', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: 1495, lookupKey: 'ent_1'},
+ { priceId: '7', desc: '', maxVehicles: 11, Vehicles: '10+', maxAcres: 'Unlimited', price: 'contact', lookupKey: ''}
+ ],
+ addon: [
+ { priceId: '1', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: 1495, lookupKey: 'addon_1', quantity: 1},
+ { priceId: '2', name: 'Aircraft Managing (Per Aircraft)', desc: '', price: 2495, lookupKey: 'addon_2', quantity: 1}
+ ],
+ }
+ const EssPkgs: Package = { priceId: '1', desc: '', maxVehicles: 1, Vehicles: '1', maxAcres: '50K', price: 995, lookupKey: 'ess_1'};
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ FormsModule,
+ TableModule,
+ ButtonModule
+ ],
+ declarations: [
+ ManageServicesComponent
+ ],
+ providers: [
+ provideMockStore({
+ selectors: [
+ {
+ selector: fromStore.selectAuthUser,
+ value: user
+ },
+ {
+ selector: getEssPkgs,
+ value: displayPackage.essential
+ },
+ {
+ selector: getEntPkgs,
+ value: displayPackage.enterprise
+ },
+ {
+ selector: getAddons,
+ value: displayPackage.addon
+ }
+ ]
+ }),
+ { provide: ActivatedRoute, useValue: {snapshot: {data: {profile: {
+ "_id": "63eaa8df132a9aefd03b2031",
+ "premium": 0,
+ "billable": false,
+ "active": true,
+ "lang": "en",
+ "markedDelete": false,
+ "kind": "1",
+ "parent": null,
+ "name": "Justin",
+ "address": null,
+ "phone": null,
+ "fax": null,
+ "email": null,
+ "contact": "Justin",
+ "username": "justin@customer.com",
+ "country": "CA"
+ }}}}
+ }
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ManageServicesComponent);
+ component = fixture.componentInstance;
+ spyOn(component, 'confirmServices');
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+
+ it('should display packages and addons with initial values', fakeAsync(() => {
+ const tables: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('[class="ui-table-tbody"]')
+ expect(tables.length).toEqual(3);
+ expect(tables[0].children.length).toEqual(2);
+ expect(tables[1].children.length).toEqual(2);
+ expect(tables[2].children.length).toEqual(2);
+ const spans: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('span')
+ expect(spans[0].innerText).toEqual('AgMission Essentials');
+ expect(spans[1].innerText).toEqual('AgMission Enterprise');
+ expect(spans[2].innerText).toEqual('Add-Ons');
+ })
+ );
+ it('confirm button should be enabled with selPkg and selAddons selected', fakeAsync(() => {
+ const btns: HTMLButtonElement[]= debugElement.nativeElement
+ .querySelectorAll('button[label="Confirm"]');
+ expect(btns[0].disabled).toBeTruthy();
+ const selections: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('tr[class="ui-selectable-row"]');
+ selections[0].click();
+ selections[2].click();
+ selections[3].click();
+ tick();
+ fixture.detectChanges();
+ expect(component.selPkg).toEqual(EssPkgs);
+ const Addons: Addon[] = [
+ { priceId: '1', name: 'Aircraft Tracking (Per Aircraft)', desc: '', price: 1495, lookupKey: 'addon_1', quantity: 1},
+ { priceId: '2', name: 'Aircraft Managing (Per Aircraft)', desc: '', price: 2495, lookupKey: 'addon_2', quantity: 1}
+ ];
+ expect(component.selAddons).toEqual(Addons);
+ expect(btns[0].disabled).toBeFalsy();
+ btns[0].click();
+ expect(component.confirmServices).toHaveBeenCalled();
+ })
+ );
+ it('confirm button should be enabled with selPkg and without selAddons selected', fakeAsync(() => {
+ const btn: HTMLButtonElement= debugElement.nativeElement
+ .querySelector('button[label="Confirm"]');
+ expect(btn.disabled).toBeTruthy();
+ const selections: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('tr[class="ui-selectable-row"]')
+ selections[0].click();
+ tick();
+ fixture.detectChanges();
+ expect(btn.disabled).toBeFalsy();
+ expect(component.selPkg).toEqual(EssPkgs);
+ expect(component.selAddons).toEqual([]);
+ btn.click();
+ expect(component.confirmServices).toHaveBeenCalled();
+ })
+ );
+ it('confirm button should be enabled without selPkg and with selAddons selected', fakeAsync(() => {
+ const btn: HTMLButtonElement= debugElement.nativeElement
+ .querySelector('button[label="Confirm"]');
+ expect(btn.disabled).toBeTruthy();
+ const selections: HTMLElement[] = debugElement.nativeElement
+ .querySelectorAll('tr[class="ui-selectable-row"]')
+ selections[3].click();
+ tick();
+ fixture.detectChanges();
+ expect(btn.disabled).toBeFalsy();
+ expect(component.selPkg).toEqual(undefined);
+ expect(component.selAddons).toEqual([displayPackage.addon[1]]);
+ btn.click();
+ expect(component.confirmServices).toHaveBeenCalled();
+ })
+ );
+
+ describe('pre-selected package and addon', () => {
+ beforeEach(() => {
+ store = TestBed.inject(MockStore);
+ fixture = TestBed.createComponent(ManageServicesComponent);
+ store.overrideSelector(getSelectedPkg, displayPackage.essential[0]);
+ store.overrideSelector(getSelectedAddons, displayPackage.addon);
+ component = fixture.componentInstance;
+ debugElement = fixture.debugElement;
+ fixture.detectChanges();
+ });
+ it('confirm button should be enabled with selPkg and selAddons pre-selected', () => {
+ const btn: HTMLButtonElement= debugElement.nativeElement
+ .querySelector('button[label="Confirm"]');
+ expect(btn.disabled).toBeFalsy();
+ });
+ })
+});
diff --git a/Development/client/src/app/profile/manage-services/manage-services.component.ts b/Development/client/src/app/profile/manage-services/manage-services.component.ts
index cbc80cf..3b6a111 100644
--- a/Development/client/src/app/profile/manage-services/manage-services.component.ts
+++ b/Development/client/src/app/profile/manage-services/manage-services.component.ts
@@ -1,16 +1,15 @@
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
-import { combineLatest, Observable, Subscription } from 'rxjs';
+import { Subscription } from 'rxjs';
import { StartBillingInfo, GotoMyServices } from '@app/actions/subscription.actions';
-import { getSubIntentState, getSubscriptions, selectSubPlansStatus, selectSubLimit, selectSubPlansLoading } from '@app/reducers'
-import { GC, globals, Labels } from '@app/shared/global';
+import { getSubIntentState, getSubscriptions, selectSubPlansStatus } from '@app/reducers'
+import { globals } from '@app/shared/global';
import { Package, Addon, Status, PriceUsd, StripeSubscription, SubscriptionIntent } from '@app/domain/models/subscription.model';
import { DateUtils, Utils } from '@app/shared/utils';
-import { SubTexts, SubAppErr, SUB, createSubStatus, SubType, Mode, SubKeys, subPlans, SERVICE_TYPE, hasVendorErr, PromoLabels } from '../common';
+import { SubTexts, SubAppErr, SUB, createSubStatus, SubType, Mode, SubKeys, subPlans, SERVICE_TYPE, hasVendorErr } from '../common';
import { BaseComp } from '@app/shared/base/base.component';
-import { map, filter } from 'rxjs/operators';
+import { map, switchMap } from 'rxjs/operators';
import { FetchSubPlans } from '@app/actions/sub-plans.actions';
import { SubscriptionService } from '@app/domain/services/subscription.service';
-import { ActivePromoService, ActivePromo } from '@app/domain/services/active-promo.service';
const DEF_QUANTITY = 1;
@@ -21,7 +20,6 @@ const DEF_QUANTITY = 1;
})
export class ManageServicesComponent extends BaseComp implements OnInit, OnDestroy {
readonly globals = globals;
- readonly Labels = Labels;
readonly SUB = SUB;
readonly SubTexts = SubTexts;
readonly pkgCols: any[];
@@ -33,7 +31,6 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
addons: Addon[];
sub$: Subscription;
status: Status;
- loading$: Observable; // Loading state observable for skeleton loader
currSel: { selPkg: Package; selAddons: Addon[] };
originalSel: { selPkg: Package; selAddons: Addon[] };
addonQuan: { [SubKeys.TRACKING]: number | string } = { [SubKeys.TRACKING]: void 0 };
@@ -45,440 +42,28 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
vendorErr: boolean;
- /** Map of lookup keys to active promos for display */
- activePromos: Map = new Map();
-
- /** Current PROMO_MODE from backend (for global kill switch) */
- promoMode: 'enabled' | 'disabled' | null = null;
-
constructor(
- private readonly subSvc: SubscriptionService,
- public readonly activePromoSvc: ActivePromoService
+ private readonly subSvc: SubscriptionService
) {
super();
this.pkgCols = [
- { header: globals.package, width: '40%' },
- { header: globals.aircraft, width: '15%' },
- { header: globals.maxAcres, width: '15%' },
- { header: globals.price, width: '30%' }
+ { header: globals.package },
+ { header: globals.aircraft },
+ { header: globals.maxAcres },
+ { header: globals.price }
];
this.addonCols = [
- { header: $localize`:@@name:Name`, width: '30%' },
- { header: $localize`:@@uPrice:Unit Price`, width: '30%' },
- { header: $localize`:@@quantity:Quantity`, width: '10%' },
- { header: $localize`:@@totalPrice:Total Price`, width: '30%' }
+ { header: $localize`:@@name:Name`, width: '50%' },
+ { header: $localize`:@@uPrice:Unit Price`, width: '15%' },
+ { header: $localize`:@@quantity:Quantity`, width: '15%' },
+ { header: $localize`:@@totalPrice:Total Price`, width: '20%' }
];
}
ngOnInit(): void {
- // Setup loading observable for skeleton loader
- this.loading$ = this.store.select(selectSubPlansLoading);
-
this.store.dispatch(new FetchSubPlans());
this.initSub$();
- this.loadActivePromos();
- this.loadPromoMode();
- }
-
- /**
- * Load current PROMO_MODE from backend (global kill switch)
- * When mode='disabled', ALL promo UI should be hidden
- */
- private loadPromoMode(): void {
- this.activePromoSvc.getCurrentMode().subscribe(mode => {
- this.promoMode = (mode?.mode as 'enabled' | 'disabled') || null;
- });
- }
-
- /**
- * Load active promos from backend and build lookup map
- * Handles three promo types:
- * 1. Exact-match promos (priceKey specified): applies to specific package/addon
- * 2. Type-only promos (type specified, priceKey null): applies to all of that type
- * 3. Universal promos (both type and priceKey null/undefined): applies to ALL packages AND addons
- */
- private loadActivePromos(): void {
- this.activePromoSvc.getActivePromos().subscribe(promos => {
- this.activePromos = new Map();
- promos.forEach(p => {
- if (p.priceKey) {
- // Priority 1: Exact match promo - keyed by priceKey (e.g., 'ess_1')
- this.activePromos.set(p.priceKey, p);
- } else if (p.type) {
- // Priority 2: Type-only promo - applies to ALL of that type
- // Store with special key convention: "package_all" or "addon_all"
- this.activePromos.set(`${p.type}_all`, p);
- } else {
- // Priority 3: Universal promo (no type, no priceKey) - applies to EVERYTHING
- // Store under both "package_all" AND "addon_all" keys
- this.activePromos.set('package_all', p);
- this.activePromos.set('addon_all', p);
- }
- });
- });
- }
-
- /**
- * Get promo for a given lookup key with display mode support (Dual-Mode Promo Display)
- * Checks for exact match first, then falls back to type-only promo
- *
- * @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
- * @param mode Display mode:
- * - 'available': Show promos only for NEW subscriptions (default - user doesn't have this item)
- * - 'subscribed': Show promos only for EXISTING subscriptions (user already has this item)
- * - 'all': Show promos regardless of subscription status
- * @returns ActivePromo if exists based on mode, null otherwise
- */
- getPromoForLookupKey(
- lookupKey: string,
- type: 'package' | 'addon' = 'package',
- mode: 'available' | 'subscribed' | 'all' = 'available'
- ): ActivePromo | null {
-
- // CRITICAL: Global kill switch - respect PROMO_MODE='disabled'
- // When backend sets mode to 'disabled', hide ALL promo UI (existing and new subscriptions)
- // Backend continues to apply existing promos (billing logic unchanged)
- // But frontend makes promo system "invisible" to user
- if (this.promoMode === 'disabled') {
- return null; // Global kill switch - hide ALL promo UI
- }
-
- const userHasThis = this.getUserSubscriptionForLookupKey(lookupKey, type);
-
- // Filter by mode
- // 'available': only show promo for items the user does NOT subscribe to
- if (mode === 'available' && userHasThis) {
- return null;
- }
-
- // 'subscribed': only show promo for items the user DOES subscribe to
- if (mode === 'subscribed' && !userHasThis) {
- return null;
- }
-
- if (mode === 'subscribed') {
- // Priority 1: promoDetails from subscription (r948+) — already billed promo
- if (userHasThis?.promoDetails?.hasPromo) {
- return this.convertPromoDetailsToActivePromo(userHasThis.promoDetails, lookupKey, type);
- }
-
- // Priority 2: For trialing subscriptions, the promo hasn't been billed yet but will apply
- // on the first invoice after trial ends. Show it from activePromos so the user can see
- // what discount they'll receive when their trial converts to paid.
- if (userHasThis?.status === 'trialing') {
- const exactMatch = this.activePromos.get(lookupKey);
- if (exactMatch) return exactMatch;
- const typeOnlyPromo = this.activePromos.get(`${type}_all`);
- if (typeOnlyPromo) return typeOnlyPromo;
- }
-
- // Non-trialing subscriptions without a promoDetails entry have no promo to show.
- return null;
- }
-
- // Priority 3: For 'available' and 'all' modes, check global activePromos
- // Exact match by priceKey
- const exactMatch = this.activePromos.get(lookupKey);
- if (exactMatch) return exactMatch;
-
- // Type-only match (priceKey: null in backend = applies to all of type)
- const typeOnlyPromo = this.activePromos.get(`${type}_all`);
- if (typeOnlyPromo) return typeOnlyPromo;
-
- return null;
- }
-
- /**
- * Get user's subscription for a specific lookup key
- * @param lookupKey Package or addon lookup key
- * @param type Subscription type
- * @returns StripeSubscription if user has this subscription, null otherwise
- */
- private getUserSubscriptionForLookupKey(
- lookupKey: string,
- type: 'package' | 'addon'
- ): StripeSubscription | null {
- if (type === 'package') {
- // For packages: Return the specific package subscription matching this lookup key
- return this.subs?.find(sub =>
- sub.metadata?.type === SubType.PACKAGE &&
- sub.items?.data?.[0]?.price?.lookup_key === lookupKey
- ) || null;
- } else {
- // For addons: Return specific addon subscription
- return this.subs?.find(sub =>
- sub.items?.data?.[0]?.price?.lookup_key === lookupKey
- ) || null;
- }
- }
-
- /**
- * Convert backend promoDetails to ActivePromo format (r948+)
- * Preserves expiry information for countdown display
- * @param promoDetails Backend promoDetails object from subscription
- * @param lookupKey Lookup key for priceKey field
- * @param type Subscription type for type field
- * @returns ActivePromo with full metadata including expiry
- */
- private convertPromoDetailsToActivePromo(
- promoDetails: any,
- lookupKey: string,
- type: 'package' | 'addon'
- ): ActivePromo {
- const discountType = this.inferDiscountType(promoDetails.discountDisplay);
- const discountValue = this.parseDiscountValue(promoDetails.discountDisplay);
-
- return {
- type: type,
- priceKey: lookupKey,
- name: promoDetails.name || 'Active Promo',
- discountType: discountType,
- discountValue: discountValue,
- validUntil: promoDetails.expiresAt || null,
- isTimeLimited: promoDetails.isTimeLimited || false,
- daysRemaining: promoDetails.daysRemaining || null
- };
- }
-
- /**
- * Infer discount type from discountDisplay string
- * @param discountDisplay String like "100% OFF", "50% OFF", "$5.00 OFF"
- * @returns Discount type: 'free' | 'percent' | 'fixed'
- */
- private inferDiscountType(discountDisplay: string): 'free' | 'percent' | 'fixed' {
- if (!discountDisplay) return 'percent';
- if (discountDisplay.includes('100%') || discountDisplay.toLowerCase().includes('free')) {
- return 'free';
- }
- if (discountDisplay.includes('%')) {
- return 'percent';
- }
- return 'fixed'; // Assumes dollar amounts like "$5.00 OFF"
- }
-
- /**
- * Parse discount value from discountDisplay string
- * @param discountDisplay String like "100% OFF", "50% OFF", "$5.00 OFF"
- * @returns Numeric discount value (100 for 100%, 50 for 50%, 500 for $5.00)
- */
- private parseDiscountValue(discountDisplay: string): number {
- if (!discountDisplay) return 0;
- const match = discountDisplay.match(/([0-9.]+)/);
- if (!match) return 0;
- const value = parseFloat(match[1]);
- // For fixed amounts like "$5.00", convert to cents
- if (discountDisplay.includes('$')) {
- return Math.round(value * 100);
- }
- return value;
- }
-
- /**
- * Map Stripe discount object to ActivePromo format
- * @param discount Stripe discount object from subscription
- * @returns ActivePromo compatible object
- */
- private mapStripeDiscountToActivePromo(discount: any): ActivePromo {
- const coupon = discount.coupon;
- const discountType = coupon.percent_off ? 'percent' : coupon.amount_off ? 'fixed' : 'free';
- const discountValue = coupon.percent_off || (coupon.amount_off ? coupon.amount_off / 100 : 0);
-
- // Generate display name based on discount type
- let name = '';
- if (discountType === 'free') {
- name = 'FREE';
- } else if (discountType === 'percent') {
- name = `${discountValue}% OFF`;
- } else {
- name = `$${discountValue.toFixed(2)} OFF`;
- }
-
- return {
- type: null, // From Stripe, not our promo system
- priceKey: null,
- name: name,
- discountType: discountType as 'free' | 'percent' | 'fixed',
- discountValue: discountValue,
- validUntil: discount.end ? new Date(discount.end * 1000).toISOString() : null,
- };
- }
-
- /**
- * Check if user is subscribed to a specific item
- * Template helper for conditional rendering
- * @param lookupKey Package or addon lookup key
- * @param type Subscription type
- * @returns true if user has this subscription, false otherwise
- */
- isUserSubscribed(lookupKey: string, type: 'package' | 'addon'): boolean {
- return this.getUserSubscriptionForLookupKey(lookupKey, type) !== null;
- }
-
- /**
- * Check if user has active legacy ESS_1 subscription
- * @returns true if user has ESS_1 subscription with active/trialing status
- */
- hasLegacyEss1Subscription(): boolean {
- return this.subs?.some(
- sub => sub.items?.data?.[0]?.price?.lookup_key === 'ess_1' &&
- sub.metadata?.type === SubType.PACKAGE &&
- (sub.status === 'active' || sub.status === 'trialing')
- ) || false;
- }
-
- /**
- * Determine if ESS_1 (legacy) should be shown in the list
- * Only show if user has active ESS_1 subscription
- * @param pkg Package to check
- * @returns true if ESS_1 should be displayed
- */
- shouldShowEss1Legacy(pkg: Package): boolean {
- return pkg.lookupKey === 'ess_1' && this.hasLegacyEss1Subscription();
- }
-
- /**
- * Determine if ESS_1_1 should be shown in the list
- * Always show ESS_1_1 (either as replacement or upgrade option)
- * @param pkg Package to check
- * @returns true if ESS_1_1 should be displayed
- */
- shouldShowEss1Plus(pkg: Package): boolean {
- return pkg.lookupKey === 'ess_1_1';
- }
-
- /**
- * Determine if standard package should be shown
- * Hide ESS_1 if user is NOT subscribed to it
- * All other packages always shown
- * @param pkg Package to check
- * @returns true if package should be displayed
- */
- shouldShowPackage(pkg: Package): boolean {
- // ESS_1: Only show if user has legacy subscription
- if (pkg.lookupKey === 'ess_1') {
- return this.hasLegacyEss1Subscription();
- }
- // ESS_1_1: Always show (handled separately for clarity)
- if (pkg.lookupKey === 'ess_1_1') {
- return true;
- }
- // All other packages: Always show
- return true;
- }
-
- /**
- * Check if package is the legacy ESS_1
- * Used for special styling and promo suppression
- * @param lookupKey Package lookup key
- * @returns true if this is legacy ESS_1
- */
- isLegacyEss1(lookupKey: string): boolean {
- return lookupKey === 'ess_1' && this.hasLegacyEss1Subscription();
- }
-
- /**
- * Check if ALL packages have the same promo (package-wide promo)
- * First checks for type-only package promo, then falls back to checking individual promos
- * Per P2-B wireframe: Banner only shown when all packages have same promo
- * CRITICAL: Only shows promo for NEW subscriptions (user has no existing package subscription)
- */
- isAllPackagesPromo(): ActivePromo | null {
- if (!this.essPkgs || this.essPkgs.length === 0) return null;
-
- // Check if user has ANY existing package subscription
- const hasExistingPackageSubscription = this.subs?.some(sub =>
- sub.metadata?.type === SubType.PACKAGE
- );
-
- // Only apply promos to NEW subscriptions (no existing package subscription)
- if (hasExistingPackageSubscription) {
- return null;
- }
-
- // Priority 1: Check for type-only package promo (applies to ALL packages)
- const typeOnlyPromo = this.activePromos.get('package_all');
- if (typeOnlyPromo) return typeOnlyPromo;
-
- // Priority 2: Check if all packages have individual promos with same discount
- const packagePromos = this.essPkgs
- .map(pkg => this.activePromos.get(pkg.lookupKey))
- .filter(Boolean) as ActivePromo[];
-
- // Check if ALL packages have a promo
- if (packagePromos.length !== this.essPkgs.length) return null;
-
- // Check if all promos are the same (same discount type and value = same campaign)
- const first = packagePromos[0];
- const allSamePromo = packagePromos.every(p =>
- p.discountType === first.discountType &&
- p.discountValue === first.discountValue
- );
-
- return allSamePromo ? first : null;
- }
-
- // NOTE: Addon banner removed per P2-D wireframe - addons never show banner
- // Promo description is shown below addon name in the Name column instead
-
- /**
- * Calculate the promo price for a given original price and promo
- * Uses centralized calculation from SubscriptionService
- * @param originalPrice Original price in cents (e.g., 99500 = $995.00)
- * @param promo Active promo
- * @returns Discounted price in cents
- */
- calculatePromoPrice(originalPrice: number, promo: ActivePromo): number {
- return this.subSvc.calculateDiscountedAmount(originalPrice, promo);
- }
-
- /**
- * Calculate the promo total for an addon (promo price × quantity)
- * @param addon Addon item
- * @param promo Active promo
- * @returns Total discounted price in dollars
- */
- calculatePromoTotal(addon: Addon, promo: ActivePromo): PriceUsd {
- if (!promo || !this.isAddonQuanValid(addon.lookupKey)) return this.calAddonTotal(addon);
- const promoUnitPrice = this.calculatePromoPrice(Number(addon.price), promo);
- return promoUnitPrice * Number(this.addonQuan[addon.lookupKey]);
- }
-
- /**
- * Format validity date for promo banner
- * @param validUntil ISO date string
- * @returns Formatted date string (e.g., "April 30, 2026")
- */
- formatPromoValidUntil(validUntil: string): string {
- if (!validUntil) return '';
- const date = new Date(validUntil);
- return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
- }
-
- /**
- * Build the promo banner message for packages
- * @param promo Active promo
- * @returns Formatted promo message string
- */
- getPackagePromoMessage(promo: ActivePromo): string {
- if (!promo) return '';
- return `${Labels.PROMO_ALL_PACKAGES_PREFIX} ${this.activePromoSvc.formatPromoDiscount(promo)} ${Labels.PROMO_UNTIL} ${this.formatPromoValidUntil(promo.validUntil)}`;
- }
-
- /**
- * Get display name for promo description (shown below package/addon name)
- * Per P2-A/P2-D wireframes: Shows "🏷️ Launch Special" style text
- * @param promo Active promo
- * @returns Localized promo description from nameKey or fallback to formatted discount
- */
- getPromoDisplayName(promo: ActivePromo): string {
- if (!promo) return '';
- // Try nameKey for localized string first, then name, then fallback to formatted discount
- if (promo.nameKey && PromoLabels[promo.nameKey]) {
- return PromoLabels[promo.nameKey];
- }
- return promo.name || this.activePromoSvc.formatPromoDiscount(promo);
}
initSub$() {
@@ -493,12 +78,8 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
service.addon = service.addon.map((addon) => {
const sub = this.subs?.find((sub) => sub.items?.data?.[0]?.price?.lookup_key === addon.lookupKey);
const selAddon = this.subIntentPkg?.selAddons?.find((selAddon) => selAddon.lookupKey === addon.lookupKey);
- const quantity = selAddon ? selAddon.quantity : sub ? sub.items?.data?.[0]?.quantity : 1;
-
- // Populate trialEnd from Stripe subscription (uses snake_case field names from Stripe API)
- const trialEnd = sub?.trial_end || sub?.current_period_end;
-
- return { ...addon, quantity, trialEnd };
+ const quantity = selAddon ? selAddon.quantity : sub ? sub.items?.data?.[0]?.quantity : 1
+ return { ...addon, quantity };
}).sort((a1: Addon, a2: Addon) => +a1.priceId - +a2.priceId);
this.essPkgs = service[SERVICE_TYPE.ESS];
@@ -507,32 +88,25 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
this.addons?.forEach((addon) => this.addonQuan[addon.lookupKey] = addon.quantity)
}
- // Use combineLatest to wait for all data streams before rendering
- // This prevents race condition where component renders with stale subPlans data
- // before FetchSubPlans effect completes
- this.sub$ = combineLatest([
- this.store.select(getSubscriptions),
- this.store.select(getSubIntentState),
- this.store.select(selectSubLimit).pipe(filter(subLimit => !!subLimit))
- ]).pipe(
- map(([subs, subIntent, subLimit]) => {
+ this.sub$ = this.store.select(getSubscriptions).pipe(
+ switchMap((subs) => {
this.subs = subs;
+ return this.store.select(getSubIntentState);
+ }),
+ map((subIntent) => {
this.subIntentPkg = subIntent?.package;
this.isTrial = subIntent?.mode === Mode.TRIALING;
this.prevStage = subIntent?.prevStage;
-
- // All data ready - initialize once with correct data
initServices();
this.initSelections();
- })
+ }),
).subscribe({
error: err => {
console.log(err);
this.status = createSubStatus(SubAppErr.MGE_SERV_ERR);
}
- });
+ })
- // Separate subscription for status updates (independent of data flow)
this.sub$.add(this.store.select(selectSubPlansStatus).subscribe({
next: (status) => {
this.status = status;
@@ -551,34 +125,13 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
try {
let orgAddons = [];
this.addons?.forEach((addon) => {
- const orgAddon = this.subs?.find((elt => elt.metadata?.type === SubType.ADDON && elt.items?.data?.[0]?.price?.lookup_key === addon?.lookupKey));
+ const orgAddon = this.subs?.find((elt => elt.metadata.type === SubType.ADDON && elt.items?.data?.[0]?.price?.lookup_key === addon?.lookupKey));
if (orgAddon) {
- // Populate trialEnd from Stripe subscription (uses snake_case field names from Stripe API)
- const trialEnd = orgAddon.trial_end || orgAddon.current_period_end;
- orgAddons.push({ ...addon, quantity: orgAddon.quantity, trialEnd });
+ orgAddons.push({ ...addon, quantity: orgAddon.quantity })
}
});
-
- // NOTE: This method works with StripeSubscription (from Stripe API) which has different field names
- // (current_period_end vs periodEnd). The centralized utilities work with AGNavSubscription.
- // We keep this logic here as it's specific to Stripe API response handling.
-
- // Find all package subscriptions
- const pkgSubs = this.subs?.filter((elt) => elt.metadata?.type === SubType.PACKAGE);
-
- // Get latest package subscription by current_period_end
- const latestPkgSub = pkgSubs?.reduce((acc, curr) => {
- return (curr.current_period_end > acc.current_period_end) ? curr : acc;
- }, pkgSubs?.[0]);
-
- const latestLookupKey = latestPkgSub?.items?.data?.[0]?.price?.lookup_key;
-
- // Populate trialEnd from latest package subscription (uses snake_case field names from Stripe API)
- const pkgTrialEnd = latestPkgSub?.trial_end || latestPkgSub?.current_period_end;
- const selPkgWithTrial = this.essPkgs?.concat(this.entPkgs).find((pkg) => pkg.lookupKey === latestLookupKey);
-
this.originalSel = {
- selPkg: selPkgWithTrial ? { ...selPkgWithTrial, trialEnd: pkgTrialEnd } : null,
+ selPkg: this.essPkgs?.concat(this.entPkgs).find((pkg) => pkg.lookupKey === this.subs?.find((elt => elt.metadata.type === SubType.PACKAGE))?.items?.data?.[0]?.price?.lookup_key) || null,
selAddons: orgAddons
};
@@ -598,89 +151,10 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
}
}
- /**
- * Frontend Display Policy for Subscription Limits
- * ===============================================
- *
- * This component displays subscription package limits to users.
- * Zero values have special meaning depending on the field.
- *
- * MaxAcres Display Rules:
- * -----------------------
- * - 0 or null → "Unlimited" (agricultural context: no acreage restriction)
- * - Positive number → Formatted value (e.g., "123", "50K")
- *
- * Rationale: In farming, "0 acres" doesn't make literal sense. Zero is used
- * internally to mean "no restriction on acreage."
- *
- * MaxVehicles Display Rules:
- * ---------------------------
- * - 0 → "0 Aircraft" (literal zero, explicit restriction shown in Vehicles column)
- * - Positive number → "X Aircraft" (e.g., "1-2", "1-5")
- *
- * Rationale: "0 aircraft" is a valid restriction (e.g., trial accounts,
- * restricted access). Display literally as received from API.
- *
- * Examples:
- * ---------
- * 1. Essential 2 Plan (regular):
- * { maxAcres: 0, maxVehicles: 2 }
- * Display: "Unlimited" acres, "1-2" Aircraft
- *
- * 2. Trial Account (restricted):
- * { maxAcres: 0, maxVehicles: 0 }
- * Display: "Unlimited" acres, "0" Aircraft
- *
- * 3. Essential 1 Plan (limited acres):
- * { maxAcres: 123, maxVehicles: 1 }
- * Display: "123" acres, "1" Aircraft
- *
- * Backend Coordination:
- * ---------------------
- * Backend API returns literal values from Stripe metadata or custom limits.
- * Frontend interprets these values according to display rules above.
- * See: server/controllers/subscription.js for backend custom limits logic
- * See: SubscriptionService.convMaxAcre() for implementation
- */
-
- /**
- * Convert maxAcres value to user-friendly display string
- * Delegates to SubscriptionService for centralized display logic
- *
- * @param maxAcres - Maximum acres value from API
- * @returns Display string ("Unlimited", "123", or "50K")
- *
- * @see SubscriptionService.convMaxAcre() for full documentation
- */
convMaxAcre(maxAcres: number | string) {
return this.subSvc.convMaxAcre(maxAcres);
}
- /**
- * Format vehicle display for package selection table
- *
- * Display Rules:
- * - maxVehicles = 0: "0" (addons without vehicle limits)
- * - maxVehicles = 1: "1"
- * - maxVehicles > 1: "Up to X" (localized)
- *
- * Handles all cases including custom limits from subscription data.
- * Uses maxVehicles property which is already populated from getPrices API
- * or custom limits via the effect pipeline.
- *
- * @param maxVehicles - Maximum vehicles value from package/addon
- * @returns Display string ("Up to 4", "1", or "0") with localized text
- */
- formatVehiclesDisplay(maxVehicles: number | string): string {
- const vehicles = Number(maxVehicles);
-
- if (vehicles > 1) {
- return `${Labels.UP_TO} ${vehicles}`;
- }
-
- return String(vehicles);
- }
-
isCompLoaded() {
return this.status?.code !== SubAppErr.MGE_SERV_ERR
&& this.status?.code !== SubAppErr.FETCH_SUB_PLANS_ERR
@@ -692,25 +166,16 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
}
isSame() {
- // Compare by lookupKey/priceId instead of deep object equality
- // This handles ESS_1 → ESS_1_1 upgrade detection correctly
- const isSamePkg = this.currSel?.selPkg?.lookupKey === this.originalSel?.selPkg?.lookupKey;
- const isSameAddons = Utils.deepEquals(this.currSel?.selAddons, this.originalSel?.selAddons);
- return isSamePkg && isSameAddons;
+ return Utils.deepEquals(this.currSel, this.originalSel);
}
isNotValidSel() {
- // Special handling for ESS_1 → ESS_1_1 upgrade (same level, but valid upgrade path)
- const isLegacyUpgrade = this.originalSel?.selPkg?.lookupKey === 'ess_1' && this.currSel?.selPkg?.lookupKey === 'ess_1_1';
-
const isPkgDowngrade = this.currSel?.selPkg?.level < this.originalSel?.selPkg?.level || (!this.currSel.selPkg && !!this.originalSel.selPkg);
let isNotValidAddon = this.currSel?.selAddons?.length < this.originalSel?.selAddons?.length;
if (this.originalSel?.selAddons?.length > 0) {
isNotValidAddon = !this.originalSel.selAddons.every((orgAdd) => this.currSel?.selAddons?.some((selAdd) => selAdd?.lookupKey === orgAdd?.lookupKey));
}
-
- // Allow ESS_1 → ESS_1_1 upgrade even though they're same level
- const isNotValid = this.isSame() || this.isEmpty || (isPkgDowngrade && !isLegacyUpgrade) || isNotValidAddon;
+ const isNotValid = this.isSame() || this.isEmpty || isPkgDowngrade || isNotValidAddon;
return isNotValid;
}
@@ -723,18 +188,10 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
}
onPkgChange() {
- if (this.isTrial && this.currSel.selPkg && this.originalSel?.selPkg?.trialEnd) {
- this.currSel.selPkg = { ...this.currSel.selPkg, trialEnd: this.originalSel.selPkg.trialEnd };
- }
this.updateIsEmpty();
}
onAddonChange() {
- if (this.isTrial && this.originalSel?.selPkg?.trialEnd) {
- this.currSel.selAddons = this.currSel.selAddons?.map((addon) => {
- return addon.trialEnd ? addon : { ...addon, trialEnd: this.originalSel.selPkg.trialEnd };
- });
- }
this.updateCurSelAddonQuan();
this.updateIsEmpty();
}
@@ -745,9 +202,9 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
const startRegularSession = (selPkg: Package, selAddons: Addon[], orgPkg: Package, orgAddons: Addon[]) => {
const prorateTS = DateUtils.currUTC();
- // if (!this.isValidTime(isSamePkg, prorateTS)) {
- // return this.msgSvc.addFailedMsg(SubTexts.textInvalidTime);
- // }
+ if (!this.isValidTime(isSamePkg, prorateTS)) {
+ return this.msgSvc.addFailedMsg(SubTexts.textInvalidTime);
+ }
if (isNewSub) {
return this.dispatchStartBillingInfo(selPkg, selAddons, orgPkg, orgAddons, prorateTS, Mode.REGULAR);
}
@@ -769,35 +226,10 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
try {
if (this.isTrial) {
- // Get trial end date from user's trial configuration for NEW trial subscriptions
- // For existing trial subscriptions, trialEnd is already populated from Stripe API in initSelections()
- const trials = this.authSvc.user?.membership?.trials;
- let trialEndDate: number = null;
- if (trials?.type === GC.BYDATE && trials?.byDate) {
- trialEndDate = new Date(trials.byDate).getTime() / 1000;
- } else if (trials?.type === GC.DAYS && trials?.trialDays) {
- // Calculate trial end date from today + trialDays
- const futureDate = new Date();
- futureDate.setDate(futureDate.getDate() + trials.trialDays);
- trialEndDate = futureDate.getTime() / 1000;
- }
-
- // Add trialEnd to selected package (for new trials or preserve existing trialEnd)
const selPkg = this.currSel.selPkg
- ? {
- ...this.currSel.selPkg,
- desc: subPlans[this.currSel.selPkg.lookupKey].desc,
- trialEnd: this.currSel.selPkg.trialEnd || trialEndDate
- }
+ ? { ...this.currSel.selPkg, desc: subPlans[this.currSel.selPkg.lookupKey].desc }
: null;
-
- // Add trialEnd to selected addons (for new trials or preserve existing trialEnd)
- const selAddons = this.currSel.selAddons.map((addon) => ({
- ...addon,
- desc: `${addon.quantity} x ${addon.name}`,
- trialEnd: addon.trialEnd || trialEndDate
- }));
-
+ const selAddons = this.currSel.selAddons.map((addon) => ({ ...addon, desc: `${addon.quantity} x ${addon.name}` }));
return startTrialSession(selPkg, selAddons, this.originalSel?.selPkg, this.originalSel?.selAddons);
}
return startRegularSession(this.currSel?.selPkg, this.currSel?.selAddons, this.originalSel?.selPkg, this.originalSel?.selAddons);
@@ -816,11 +248,7 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
};
const isPkgTimeValid = (): boolean => {
- // Use centralized utility to get latest package subscription
- const pkg = this.subSvc.getLatestSubscription(
- this.authSvc.user?.membership?.subscriptions,
- SubType.PACKAGE
- );
+ const pkg = this.authSvc.user?.membership?.subscriptions?.find((sub) => sub.type === SubType.PACKAGE);
return isSamePkg || !isLessThanOneDay(pkg?.periodStart);
};
@@ -836,27 +264,10 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
}
private dispatchStartBillingInfo(selPkg: Package, selAddons: Addon[], orgPkg: Package, orgAddons: Addon[], prorateTS: number, mode: Mode) {
- // Apply custom limits to selected package if user has custom limits
- const customMaxVehicles = this.authSvc.user?.membership?.customLimits?.maxVehicles;
- const customMaxAcres = this.authSvc.user?.membership?.customLimits?.maxAcres;
-
- // Handle addon-only subscription (no package selected)
- // Override package limits with custom limits for the entire subscription flow
- // Use explicit null/undefined check to handle 0 values (e.g., disabled vehicles)
- const packageWithCustomLimits = selPkg ? {
- ...selPkg,
- maxVehicles: (customMaxVehicles !== null && customMaxVehicles !== undefined)
- ? customMaxVehicles
- : selPkg.maxVehicles,
- maxAcres: (customMaxAcres !== null && customMaxAcres !== undefined)
- ? customMaxAcres
- : selPkg.maxAcres
- } : null;
-
this.store.dispatch(new StartBillingInfo({
applicatorId: this.authSvc.user?._id,
custId: this.authSvc.user?.membership.custId,
- selPkg: packageWithCustomLimits,
+ selPkg,
selAddons,
orgPkg,
orgAddons,
@@ -912,20 +323,6 @@ export class ManageServicesComponent extends BaseComp implements OnInit, OnDestr
});
}
- /**
- * Get billing duration label for display in caption subtitle
- * @param interval - Stripe interval ('year' or 'month')
- * @returns Localized duration label
- */
- getDurationLabel(interval: string): string {
- if (interval === 'year') {
- return $localize`:@@annualBilling:Annual billing`;
- } else if (interval === 'month') {
- return $localize`:@@monthlyBilling:Monthly billing`;
- }
- return interval; // Fallback to raw interval
- }
-
ngOnDestroy(): void {
super.ngOnDestroy();
}
diff --git a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.css b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.css
index c0e6932..7c0196b 100644
--- a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.css
+++ b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.css
@@ -3,8 +3,8 @@
}
.feature-content ul {
- margin: 0;
- padding: 4px 20px;
+ margin : 0;
+ padding : 4px 20px;
}
.feature-content ul li:not(:last-child) {
@@ -12,7 +12,7 @@
}
.feature-content ul li i {
- margin-right: 16px;
+ margin-right : 16px;
vertical-align: middle;
}
@@ -31,702 +31,4 @@
.edit-dialog {
width: 30vw;
-}
-
-/* Promo display styling in subscription list */
-.promo-info {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
-}
-
-.promo-icon {
- color: #FFC107;
- /* AgMission amber accent */
- font-size: 1.125rem;
-}
-
-.promo-label {
- display: inline-block;
- background-color: #FFC107;
- /* AgMission amber */
- color: #212121;
- /* Dark text for contrast */
- font-size: 0.75em;
- font-weight: 600;
- padding: 2px 8px;
- border-radius: 3px;
- /* AgMission standard border-radius */
- text-transform: uppercase;
- letter-spacing: 0.5px;
- white-space: nowrap;
-}
-
-.promo-validity {
- font-size: 0.85em;
- color: #757575;
- /* AgMission secondary text */
- font-style: italic;
-}
-
-/* Data integrity warning for multiple packages */
-.data-integrity-warning {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 12px 16px;
- margin-bottom: 16px;
- background-color: #FFF3E0;
- /* Light amber background */
- border: 1px solid #FF9800;
- /* AgMission warning orange */
- border-left: 4px solid #FF9800;
- border-radius: 3px;
- color: #E65100;
- /* Dark orange text */
- font-size: 0.9rem;
-}
-
-.data-integrity-warning i {
- font-size: 1.25rem;
- color: #FF9800;
- flex-shrink: 0;
-}
-
-/* ✅ NEW r948: Promo badge from promoDetails object */
-.promo-badge {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 4px 8px;
- background: #E3F2FD;
- color: #1976D2;
- border-radius: 4px;
- font-size: 0.875rem;
- font-weight: 600;
-}
-
-/* Case 2A: Retention badge (subscription has promo) */
-.promo-badge.retention {
- background: #FFF3E0;
- color: #E65100;
- border-left: 3px solid #FF9800;
-}
-
-/* Case 2B: Acquisition badge (subscription does NOT have promo) */
-.promo-badge.acquisition {
- background: #E8F5E9;
- color: #2E7D32;
- border-left: 3px solid #4CAF50;
-}
-
-.promo-badge .pi {
- font-size: 0.875rem;
-}
-
-/* Case 2A: Expiry warning (urgent retention message) */
-.promo-expiry-warning {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- margin-left: 8px;
- padding: 4px 8px;
- background: #FFEBEE;
- border-left: 3px solid #F44336;
- border-radius: 3px;
- color: #C62828;
- font-size: 0.8125rem;
- font-weight: 600;
-}
-
-.promo-expiry-warning .pi {
- font-size: 0.75rem;
-}
-
-/* ============================================================================
- WIREFRAME 1: VERTICAL CARD LAYOUT - PROMOTION DISPLAY
- ============================================================================ */
-
-/* Promotion Card Container */
-.promotion-card {
- background: linear-gradient(135deg, #f0f9f0 0%, #ffffff 100%);
- border: 2px solid #4CAF50;
- border-radius: 8px;
- padding: 24px;
- margin-bottom: 16px;
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
- transition: all 0.3s ease;
-}
-
-.promotion-card:hover {
- box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
- transform: translateY(-2px);
-}
-
-/* Promotion Card Header */
-.promotion-card-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- flex-wrap: wrap;
- gap: 12px;
-}
-
-/* Discount Badge */
-.discount-badge {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 8px 16px;
- background: #4CAF50;
- color: #ffffff;
- font-size: 16px;
- font-weight: 700;
- border-radius: 20px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- box-shadow: 0 2px 4px rgba(76, 175, 80, 0.3);
-}
-
-.discount-badge.limited-time {
- background: #FF9800;
- box-shadow: 0 2px 4px rgba(255, 152, 0, 0.3);
- animation: pulse-badge 2s ease-in-out infinite;
-}
-
-.discount-badge.permanent {
- background: #2E7D32;
-}
-
-.discount-badge .pi {
- font-size: 14px;
-}
-
-/* Package Name */
-.package-name {
- font-size: 20px;
- font-weight: 600;
- color: #212121;
-}
-
-/* Promotion Card Body */
-.promotion-card-body {
- display: flex;
- flex-direction: column;
- gap: 20px;
-}
-
-/* Price Section */
-.price-section {
- text-align: left;
-}
-
-.current-price-label {
- font-size: 14px;
- color: #757575;
- margin-bottom: 8px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.promotional-price {
- display: flex;
- align-items: baseline;
- gap: 4px;
- color: #2E7D32;
- margin-bottom: 12px;
-}
-
-.promotional-price .currency {
- font-size: 32px;
- font-weight: 600;
-}
-
-.promotional-price .amount {
- font-size: 48px;
- font-weight: 700;
- line-height: 1;
-}
-
-.promotional-price .period {
- font-size: 18px;
- color: #757575;
-}
-
-/* Pricing Breakdown */
-.pricing-breakdown {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 16px;
- background: #ffffff;
- border-radius: 6px;
- border-left: 4px solid #4CAF50;
-}
-
-.regular-price {
- font-size: 16px;
- color: #757575;
-}
-
-.regular-price .strikethrough {
- text-decoration: line-through;
- font-weight: 500;
-}
-
-.savings-highlight {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 18px;
- font-weight: 600;
- color: #4CAF50;
-}
-
-.savings-highlight .pi {
- font-size: 16px;
-}
-
-.savings-amount {
- font-weight: 700;
-}
-
-/* Renewal Notice */
-.renewal-notice {
- padding: 16px;
- background: #FFF3E0;
- border-left: 4px solid #FF9800;
- border-radius: 6px;
-}
-
-.renewal-notice.permanent {
- background: #E8F5E9;
- border-left: 4px solid #4CAF50;
-}
-
-.expiry-warning {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 14px;
- font-weight: 600;
- color: #E65100;
- margin-bottom: 8px;
-}
-
-.expiry-warning .pi {
- font-size: 16px;
-}
-
-.permanent-discount-info {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 14px;
- font-weight: 600;
- color: #2E7D32;
- margin-bottom: 8px;
-}
-
-.permanent-discount-info .pi {
- font-size: 16px;
-}
-
-.renewal-info {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 14px;
- color: #424242;
-}
-
-.renewal-info .pi {
- font-size: 14px;
- color: #757575;
-}
-
-/* Pulse Animation for Limited-Time Badge */
-
-/* ============================================================================
- COMPACT VERTICAL LIST STYLING (Wireframe 4)
- ============================================================================ */
-
-/* Compact Vertical Card Container */
-.compact-vertical-card {
- padding: 12px 16px;
- border-radius: 6px;
- margin-bottom: 16px;
-}
-
-/* Header Section */
-.cv-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
-}
-
-.cv-package-info {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.cv-separator {
- color: #BDBDBD;
- font-weight: 300;
- font-size: 14px;
-}
-
-.cv-package-name {
- font-size: 16px;
- font-weight: 600;
- color: #212121;
-}
-
-/* Divider */
-.cv-divider {
- height: 1px;
- background: #E0E0E0;
- margin: 8px 0;
-}
-
-/* Sections */
-.cv-section {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-/* Section Headers for Promo and Details */
-.section-header {
- font-size: 14px;
- font-weight: 700;
- color: #2E7D32;
- /* AgMission dark green */
- text-transform: uppercase;
- letter-spacing: 0.5px;
- margin: 0 0 8px 0;
- padding-bottom: 4px;
- border-bottom: 2px solid #4CAF50;
- /* AgMission primary green */
-}
-
-/* Rows (Label: Value pairs) */
-.cv-row {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- font-size: 14px;
- line-height: 1.4;
-}
-
-.cv-label {
- color: #757575;
- font-weight: 500;
- flex-shrink: 0;
- margin-right: 12px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.cv-value {
- color: #212121;
- font-weight: 400;
- text-align: right;
-}
-
-/* Pricing-specific styles */
-.cv-current-price {
- color: #2E7D32;
- font-weight: 600;
-}
-
-.cv-savings {
- font-size: 12px;
- color: #4CAF50;
- margin-left: 4px;
-}
-
-.cv-future-price {
- color: #616161;
-}
-
-/* Promo details text styling within cv-value */
-.cv-value.promo-text {
- font-size: 0.875rem;
- /* 14px - matches cv-row font size */
- font-weight: 400;
- /* Matches cv-value weight */
- color: #212121;
- /* AgMission primary text color */
- line-height: 1.4;
- /* Matches cv-row line-height */
-}
-
-/* ============================================================================
- ENHANCED PROMO DISPLAY STYLING (8-Case System)
- ============================================================================ */
-
-/* Promo Section with Urgency State */
-.cv-promo.promo-urgent {
- background: #FFF3E0;
- /* Light amber background for urgency */
- border-left: 3px solid #FF9800;
- /* Amber border for visual emphasis */
- padding: 8px 12px;
- border-radius: 3px;
- margin: 4px 0;
-}
-
-/* Promo Type Header Row */
-.promo-header {
- display: flex;
- align-items: center;
- gap: 8px;
- font-weight: 600;
- color: #212121;
- margin-bottom: 4px;
-}
-
-/* Promo Type Icon */
-.promo-type-icon {
- font-size: 18px;
- flex-shrink: 0;
- color: #4CAF50;
- /* AgMission green */
-}
-
-/* Promo Type Label */
-.promo-type-label {
- font-size: 14px;
- font-weight: 600;
- color: #2E7D32;
- /* AgMission dark green */
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-/* Promo Expiry Row */
-.promo-expiry {
- color: #616161;
- /* Neutral gray for informational text */
- font-style: italic;
-}
-
-/* Promo Duration Row (Standard State) */
-.promo-duration {
- color: #757575;
- /* AgMission secondary text */
- font-weight: 500;
-}
-
-/* Promo Duration Row (Urgent State) */
-.promo-urgent-text {
- color: #F44336;
- /* AgMission red for urgent warnings */
- font-weight: 600;
- animation: pulse-urgent-text 2s ease-in-out infinite;
-}
-
-/* Urgent Text Pulse Animation */
-@keyframes pulse-urgent-text {
-
- 0%,
- 100% {
- opacity: 1;
- }
-
- 50% {
- opacity: 0.7;
- }
-}
-
-/* ============================================================================
- CASE 2C: TRIAL SUBSCRIPTION WITH PROMO DISPLAY
- ============================================================================ */
-
-/* Promo Badge Row */
-.cv-promo-badge-row {
- justify-content: flex-start;
- margin: 8px 0;
-}
-
-/* After-Trial Pricing Section with Promo - Consistent with regular flow */
-
-/* Discounted Price Styling - matches regular .cv-current-price */
-.cv-discounted-price {
- color: #2E7D32;
- font-weight: 600;
-}
-
-.cv-promo-indicator {
- font-size: 12px;
- color: #4CAF50;
- margin-left: 4px;
-}
-
-/* Regular Price Strikethrough - matches regular .cv-value */
-.cv-strikethrough {
- text-decoration: line-through;
- color: #616161;
-}
-
-/* Pulse Animation for Limited-Time Badge */
-@keyframes pulse-badge {
-
- 0%,
- 100% {
- box-shadow: 0 2px 4px rgba(255, 152, 0, 0.3);
- }
-
- 50% {
- box-shadow: 0 4px 12px rgba(255, 152, 0, 0.6);
- }
-}
-
-/* Responsive adjustments for promoDetails display */
-@media (max-width: 768px) {
- .promo-badge {
- font-size: 0.75rem;
- }
-
- .promo-expiry-warning {
- display: block;
- margin-left: 0;
- margin-top: 4px;
- }
-
- /* Wireframe 1: Mobile responsive adjustments */
- .promotion-card {
- padding: 16px;
- }
-
- .promotion-card-header {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .discount-badge {
- font-size: 14px;
- padding: 6px 12px;
- }
-
- .package-name {
- font-size: 18px;
- }
-
- .promotional-price .currency {
- font-size: 24px;
- }
-
- .promotional-price .amount {
- font-size: 36px;
- }
-
- .promotional-price .period {
- font-size: 16px;
- }
-
- .pricing-breakdown {
- padding: 12px;
- }
-
- .regular-price {
- font-size: 14px;
- }
-
- .savings-highlight {
- font-size: 16px;
- }
-
- .renewal-notice {
- padding: 12px;
- }
-
- .expiry-warning,
- .permanent-discount-info,
- .renewal-info {
- font-size: 12px;
- }
-}
-
-/* Tablet responsive adjustments */
-@media (min-width: 769px) and (max-width: 1024px) {
- .promotion-card {
- padding: 20px;
- }
-
- .promotional-price .currency {
- font-size: 28px;
- }
-
- .promotional-price .amount {
- font-size: 42px;
- }
-}
-
-/* High contrast mode support */
-@media (prefers-contrast: high) {
- .promotion-card {
- border: 3px solid #2E7D32;
- }
-
- .discount-badge {
- border: 2px solid #ffffff;
- }
-
- .pricing-breakdown {
- border-left: 5px solid #4CAF50;
- }
-
- .renewal-notice {
- border-left: 5px solid #FF9800;
- }
-
- .renewal-notice.permanent {
- border-left: 5px solid #4CAF50;
- }
-}
-
-/* ============================================================================
- DUAL-PERIOD PROMO DISPLAY (Issue 2 - Deferred Promo)
- ============================================================================ */
-
-/* Next period promo icon (green star indicator) */
-.next-period-promo-icon {
- color: #4CAF50;
- /* AgMission primary green */
- font-size: 0.875rem;
- /* 14px - slightly smaller than base text */
- margin-right: 4px;
- vertical-align: middle;
-}
-
-/* Next period promo value (green amount with emphasis) */
-.next-period-promo-value {
- color: #4CAF50;
- /* AgMission primary green */
- font-weight: 500;
- /* Medium weight for emphasis */
-}
-
-/* Next billing date label (standard styling) */
-.next-billing-date-label {
- color: #212121;
- /* AgMission primary text */
-}
-
-/* Next billing date value (standard styling) */
-.next-billing-date-value {
- color: #212121;
- /* AgMission primary text */
-}
-
-.promo-note {
- text-transform: none;
- font-style: italic;
}
\ No newline at end of file
diff --git a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.html b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.html
index 9e8c265..64b7a44 100644
--- a/Development/client/src/app/profile/manage-subscription/manage-subscription.component.html
+++ b/Development/client/src/app/profile/manage-subscription/manage-subscription.component.html
@@ -1,9 +1,8 @@
-
+
-
Hello {{profileUser?.contact}}
-
+
Hello {{profileUser?.contact}}
@@ -24,32 +23,22 @@
-
Total
- Balance : {{totalBalance}}
+ Total Balance : {{totalBalance}}
-
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
@@ -60,8 +49,7 @@
-
-
+
@@ -85,8 +73,7 @@
-
+
@@ -98,23 +85,18 @@
- Name : {{pmDefault.billing_details?.name}}
-
- {{crtCardDesc(pmDefault.card?.brand,
- pmDefault.card?.last4)}}
- Expiration
- date : {{crtExp(pmDefault.card?.exp_month, pmDefault.card?.exp_year)}}
+ Name : {{pmDefault.billing_details?.name}}
+ {{crtCardDesc(pmDefault.card?.brand, pmDefault.card?.last4)}}
+ Expiration date : {{crtExp(pmDefault.card?.exp_month, pmDefault.card?.exp_year)}}
{{crtCardDesc(pmDefault.card?.brand, pmDefault.card?.last4)}}
- Expiration
- date : {{crtExp(pmDefault.card?.exp_month, pmDefault.card?.exp_year)}}
+ Expiration date : {{crtExp(pmDefault.card?.exp_month, pmDefault.card?.exp_year)}}
-
+
@@ -124,19 +106,11 @@
Package
-
-
-
- {{ Labels.MULTIPLE_PACKAGES_WARNING }}
-
-
- Subscription end date : {{periodEnd | tsToDate: lang
- }}
+ Subscription end date : {{periodEnd | tsToDate: lang }}
- Max vehicles : {{vehicles}} Aircraft
+ Max vehicles : {{vehicles}} Aircraft
Billing cycle : {{cycle}}
@@ -147,325 +121,25 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Trial Ends:
- {{ formatTrialEndDate(pkg.trialEnd) }}
-
-
-
- Regular Price:
- ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year
-
-
-
-
-
-
-
-
-
-
-
- Trial Ends:
- {{ formatTrialEndDate(pkg.trialEnd) }}
-
-
-
- Regular Price:
- ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year
-
-
-
-
- {{ Labels.PAID_PRICE }}:
-
- {{ getAfterTrialPrice(pkg) }}
- 0">(save ${{
- getSavingsAmount(pkg, fullPkg) | number:'1.2-2' }})
-
-
-
-
-
- After Promo Ends:
-
- ${{ getRegularPrice(pkg, fullPkg) | number:'1.2-2' }}/year
-
-
-
-
-
-
-
-
-
-
-
- Trial Ends:
- {{ pkg.trialEnd | tsToDate: lang }}
-
-
- After Trial:
- ${{ (fullPkg.price / 100) | number:'1.2-2' }}/year
-
-
+
+
-
-
-
-
-
-
- Max Vehicles:
- {{ getMaxVehicles(pkg, fullPkg) }} Aircraft
-
-
-
- Max Acres:
- {{ getMaxAcres(pkg, fullPkg) > 0 ? (getMaxAcres(pkg, fullPkg) | number:'1.0-0') :
- SubTexts.unlimited }}
-
-
-
- Billing Cycle:
- {{ SubTexts.yearly }}
-
-
-
- Payment Method:
- {{ pkg.paymentMethod }}
-
-
-
-
-
-
-
-
- Next Bill Date:
- {{ pkg.periodEnd | tsToDate: lang }}
-
-
-
-
- {{ isCustomerInCanada() ? (pkg.status === SubStripe.TRIALING ? Labels.NEXT_BILL_AMOUNT_BEFORE_TAX : Labels.NEXT_BILL_AMOUNT_INCL_TAX) : Labels.NEXT_BILL_AMOUNT }}
-
- {{ nextBillAmounts[pkg.lookupKey] }}
-
-
- Loading...
-
-
-
-
-
-
-
- Promo:
-
- {{ pending.discountDisplay }}
-
- next billing period
-
-
- for
- {{ getPendingPromoDurationMonths(pending) }}
- months
-
-
- forever
-
-
- (save
- {{ pendingPromoSavings[pkg.lookupKey] }}/mo )
-
-
-
-
-
-
-
-
- Next Period Amount
-
-
- ${{ (nextPeriodCharge[pkg.lookupKey] / 100).toFixed(2) }} US
-
-
-
-
-
- Next Billing Date
- {{ nextBillingDate[pkg.lookupKey] | date:'MMM d, yyyy' }}
-
-
-
-
- Subscription end date
- {{ pkg.periodEnd | tsToDate: lang }}
-
-
-
-
+
+
+
-
-
-
+
@@ -474,302 +148,24 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Trial Ends:
- {{ formatTrialEndDate(addon.trialEnd) }}
-
-
-
- Regular Price:
- ${{ (fullAddon.price / 100) | number:'1.2-2' }}/month
-
-
-
-
-
-
-
-
-
-
-
- Trial Ends:
- {{ formatTrialEndDate(addon.trialEnd) }}
-
-
-
-
-
- {{ Labels.PAID_PRICE }}:
-
- {{ getAfterTrialPrice(addon) }}
- 0">(save ${{
- getSavingsAmount(addon, fullAddon) | number:'1.2-2' }})
-
-
-
-
-
- After Promo Ends:
-
- ${{ getRegularPrice(addon, fullAddon) | number:'1.2-2' }}/month
-
-
-
-
-
-
-
-
-
-
-
- Trial Ends:
- {{ addon.trialEnd | tsToDate: lang }}
-
-
- After Trial:
- ${{ (fullAddon.price / 100) | number:'1.2-2' }}/month
-
-
+
+
-
-
-
-
-
-
- Max Vehicles:
- {{ getMaxVehicles(addon, fullAddon) }} Aircraft
-
-
-
- Billing Cycle:
- {{ SubTexts.monthly }}
-
-
-
- Payment Method:
- {{ addon.paymentMethod }}
-
-
-
-
-
-
-
-
- Next Bill Date:
- {{ addon.periodEnd | tsToDate: lang }}
-
-
-
-
- {{ isCustomerInCanada() ? (addon.status === SubStripe.TRIALING ? Labels.NEXT_BILL_AMOUNT_BEFORE_TAX : Labels.NEXT_BILL_AMOUNT_INCL_TAX) : Labels.NEXT_BILL_AMOUNT }}
-
- {{ nextBillAmounts[addon.lookupKey] }}
-
-
- Loading...
-
-
-
-
-
-
-
- Promo:
-
- {{ pending.discountDisplay }}
-
- next billing period
-
-
- for
- {{ getPendingPromoDurationMonths(pending) }}
- months
-
-
- forever
-
-
- (save
- {{ pendingPromoSavings[addon.lookupKey] }}/mo )
-
-
-
-
-
-
-
-
- Next Period Amount
-
-
- ${{ (nextPeriodCharge[addon.lookupKey] / 100).toFixed(2) }} US
-
-
-
-
-
- Next Billing Date
- {{ nextBillingDate[addon.lookupKey] | date:'MMM d, yyyy' }}
-
-
-
-
- Subscription end date
- {{ addon.periodEnd | tsToDate: lang }}
-
-
-
-
+
+
+
-
-
-
+
@@ -783,30 +179,24 @@
Usage
-
+
-
+
Edit Subscriptions
@@ -815,19 +205,15 @@
Addons
-
-
+
+
-
-
+
+
@@ -835,16 +221,12 @@
@@ -881,16 +263,14 @@
-
+
-
+
@@ -900,8 +280,7 @@
{{name}} Trial
- {{name}} Trial
- ends {{periodEnd | tsToDate: lang }}
+ {{name}} Trial ends {{periodEnd | tsToDate: lang }}
@@ -919,8 +298,7 @@
{{name}} Trial
- {{name}} Trial
- ends {{periodEnd | tsToDate: lang }}
+ {{name}} Trial ends {{periodEnd | tsToDate: lang }}
@@ -937,18 +315,15 @@
- Next charge on : {{periodEnd | tsToDate: lang
- }}
+ Next charge on : {{periodEnd | tsToDate: lang }}
- 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..12feccd 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 { 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);
@@ -1300,941 +472,6 @@ export class ManageSubscriptionComponent extends BaseComp implements OnInit, OnD
|| this.isResolvePM();
}
- hasValidTrialType() {
- 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 @@
-
+
Summary
@@ -21,8 +21,7 @@
Refunded to
{{invoice?.billing_details?.name}}
{{invoice?.billing_details?.address?.line1}}
-
{{invoice?.billing_details?.address?.city}} {{invoice?.billing_details?.address?.state}}
- {{invoice?.billing_details?.address?.postal_code}}
+
{{invoice?.billing_details?.address?.city}} {{invoice?.billing_details?.address?.state}} {{invoice?.billing_details?.address?.postal_code}}
{{invoice?.billing_details?.address?.country}}
{{invoice?.receipt_email}}
@@ -41,8 +40,7 @@
Billed to
{{invoice?.customer_name}}
{{invoice?.customer_address?.line1}}
-
{{invoice?.customer_address?.city}} {{invoice?.customer_address?.state}}
- {{invoice?.customer_address?.postal_code}}
+
{{invoice?.customer_address?.city}} {{invoice?.customer_address?.state}} {{invoice?.customer_address?.postal_code}}
{{invoice?.customer_address?.country}}
{{invoice?.customer_email}}
@@ -98,10 +96,8 @@
-
-
+
+
Amount due
@@ -125,14 +121,12 @@
Fees
-
{{getCharge()?.amount - getCharge()?.amount_refunded |
- usCurrency}}
+
{{getCharge()?.amount - getCharge()?.amount_refunded | usCurrency}}
Credited Total
-
{{getCharge()?.amount_refunded | usCurrency | creditCurrency}}
-
+
{{getCharge()?.amount_refunded | usCurrency | creditCurrency}}
@@ -143,11 +137,9 @@
@@ -157,16 +149,13 @@
-
+
\ No newline at end of file
diff --git a/Development/client/src/app/profile/payment-detail/payment-detail.component.ts b/Development/client/src/app/profile/payment-detail/payment-detail.component.ts
index f78a2c3..c0d45c0 100644
--- a/Development/client/src/app/profile/payment-detail/payment-detail.component.ts
+++ b/Development/client/src/app/profile/payment-detail/payment-detail.component.ts
@@ -65,7 +65,7 @@ export class PaymentDetailComponent extends BaseComp implements OnInit, OnDestro
return this.status?.code !== SubAppErr._500_ERR && this.status?.code !== SubAppErr.PM_DETAIL_ERR;
}
- isPaid(item: Invoice | Charge | undefined) {
+ isPaid(item: Invoice | Charge) {
return !!item?.paid;
}
@@ -101,10 +101,6 @@ export class PaymentDetailComponent extends BaseComp implements OnInit, OnDestro
return !!item?.tax
}
- hasDiscount(item: Invoice) {
- return !!(item?.discount || (item as any)?.total_discount_amounts?.length > 0);
- }
-
gotoMySubs() {
this.router.navigate([SUB.PROFILE, SUB.MY_SERVICES]);
}
diff --git a/Development/client/src/app/profile/payment-history/payment-history.component.html b/Development/client/src/app/profile/payment-history/payment-history.component.html
index 01b7860..78c67df 100644
--- a/Development/client/src/app/profile/payment-history/payment-history.component.html
+++ b/Development/client/src/app/profile/payment-history/payment-history.component.html
@@ -1,6 +1,6 @@
-
+
Payment history
diff --git a/Development/client/src/app/profile/payment-history/payment-history.component.spec.ts b/Development/client/src/app/profile/payment-history/payment-history.component.spec.ts
new file mode 100644
index 0000000..ecd188e
--- /dev/null
+++ b/Development/client/src/app/profile/payment-history/payment-history.component.spec.ts
@@ -0,0 +1,25 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { PaymentHistoryComponent } from './payment-history.component';
+
+describe('PaymentHistoryComponent', () => {
+ let component: PaymentHistoryComponent;
+ let fixture: ComponentFixture
;
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ declarations: [ PaymentHistoryComponent ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(PaymentHistoryComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/Development/client/src/app/profile/profile-routing.module.ts b/Development/client/src/app/profile/profile-routing.module.ts
index d9bc8b0..590cd50 100644
--- a/Development/client/src/app/profile/profile-routing.module.ts
+++ b/Development/client/src/app/profile/profile-routing.module.ts
@@ -22,7 +22,6 @@ import { UsageComponent } from './usage/usage.component';
import { UsageDetailGuard } from '@app/domain/guards/usage-detail.guard';
import { PaymentMethodListComponent } from './payment-method-list/payment-method-list.component';
import { StripeLoadGuard } from '@app/domain/guards/stripe-load.guard';
-import { BillingAddressListComponent } from './billing-address-list/billing-address-list.component';
const routes: Routes = [
{
@@ -98,17 +97,6 @@ const routes: Routes = [
user: UserResolver
}
},
- {
- path: SUB.BILL_ADR_LIST,
- component: BillingAddressListComponent,
- data: {
- roles: [RoleIds.APP]
- },
- canActivate: [RoleGuard, StripeLoadGuard],
- resolve: {
- user: UserResolver
- }
- },
{
path: SUB.CHKOUT,
component: CheckoutComponent,
diff --git a/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.spec.ts b/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.spec.ts
new file mode 100644
index 0000000..0eb6b4e
--- /dev/null
+++ b/Development/client/src/app/profile/unpaid-subscription/unpaid-subscription.component.spec.ts
@@ -0,0 +1,144 @@
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { UnpaidSubscriptionComponent } from './unpaid-subscription.component';
+import { provideMockStore } from '@ngrx/store/testing';
+import { getUnpaidInvoices, getUnpaidSubs } from '../selectors/profile.selector';
+import { selectAuthUser } from '../../reducers/index';
+import { UserModel } from '@app/auth/models/user.model';
+import { Invoice, UnpaidSubscription } from '@app/domain/models/subscription.model';
+import { AppSharedModule } from '@app/shared/app-shared.module';
+import { ActivatedRoute } from '@angular/router';
+describe('UnpaidSubscriptionComponent', () => {
+ let component: UnpaidSubscriptionComponent;
+ let fixture: ComponentFixture;
+ 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 unpaidSubs: UnpaidSubscription[] = [
+ {
+ lookupKey: 'ess_1',
+ id: '1231',
+ tax: 1,
+ total: 3,
+ total_excluding_tax: 2,
+ subtotal: 2,
+ subtotal_excluding_tax: 1
+ },
+ {
+ lookupKey: 'addon_1',
+ id: '3457',
+ tax: 1,
+ total: 3,
+ total_excluding_tax: 2,
+ subtotal: 2,
+ subtotal_excluding_tax: 1
+ }
+ ];
+ const unpaidInvoices: Invoice[] = [
+ {
+ id: '1',
+ subscription: '1231',
+ tax: 1,
+ total: 3,
+ total_excluding_tax: 2,
+ subtotal: 2,
+ subtotal_excluding_tax: 1
+ },
+ {
+ id: '2',
+ subscription: '3457',
+ tax: 1,
+ total: 3,
+ total_excluding_tax: 2,
+ subtotal: 2,
+ subtotal_excluding_tax: 1
+ }
+ ]
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ AppSharedModule,
+ ],
+ declarations: [ UnpaidSubscriptionComponent ],
+ providers: [
+ provideMockStore({
+ selectors: [
+ {
+ selector: selectAuthUser,
+ value: user
+ },
+ {
+ selector: getUnpaidSubs,
+ value: unpaidSubs
+ },
+ {
+ selector: getUnpaidInvoices,
+ value: unpaidInvoices
+ }
+ ]
+ }),
+ { provide: ActivatedRoute, useValue: {snapshot: {data: {user: {
+ "_id": "63eaa8df132a9aefd03b2031",
+ "premium": 0,
+ "billable": false,
+ "active": true,
+ "lang": "en",
+ "markedDelete": false,
+ "kind": "1",
+ "parent": null,
+ "name": "Justin",
+ "address": null,
+ "phone": null,
+ "fax": null,
+ "email": null,
+ "contact": "Justin",
+ "username": "justin@customer.com",
+ "country": "CA"
+ }}}}
+ }
+ ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(UnpaidSubscriptionComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/Development/client/src/app/profile/update-profile/update-profile.component.html b/Development/client/src/app/profile/update-profile/update-profile.component.html
index 190d279..6743c20 100644
--- a/Development/client/src/app/profile/update-profile/update-profile.component.html
+++ b/Development/client/src/app/profile/update-profile/update-profile.component.html
@@ -7,22 +7,13 @@
-
-
- {{globals.accountType}}: {{accountType}}
-
-
- {{globals.masterAcc}}: {{parentUsername}}
-
-
-
-
+
+
diff --git a/Development/client/src/app/profile/update-profile/update-profile.component.ts b/Development/client/src/app/profile/update-profile/update-profile.component.ts
index 4218770..488c3ce 100644
--- a/Development/client/src/app/profile/update-profile/update-profile.component.ts
+++ b/Development/client/src/app/profile/update-profile/update-profile.component.ts
@@ -8,7 +8,7 @@ import { BaseComp } from '@app/shared/base/base.component';
import * as profileAction from '../actions/profile.actions';
import { globals, RoleIds } from '@app/shared/global';
import { FormGroup, FormBuilder } from '@angular/forms';
-import { UserService } from '@app/domain/services/user.service';
+import { FetchSubPlans } from '@app/actions/sub-plans.actions';
@Component({
selector: 'agm-user-profile-update',
@@ -19,9 +19,7 @@ export class ProfileUpdateComponent extends BaseComp implements OnInit, OnDestro
constructor(
private readonly route: ActivatedRoute,
- private readonly fb: FormBuilder,
- private userSvc: UserService
- ) {
+ private readonly fb: FormBuilder) {
super();
this.form = this.fb.group({
@@ -32,8 +30,6 @@ export class ProfileUpdateComponent extends BaseComp implements OnInit, OnDestro
form: FormGroup;
selectedItem: User;
- parentUsername?: string;
- accountType: string;
_user: User;
get user(): User {
@@ -50,17 +46,24 @@ export class ProfileUpdateComponent extends BaseComp implements OnInit, OnDestro
ngOnInit(): void {
this.sub$ = this.route.data.subscribe((data) => {
- const resolved = data[0] as { user: User, parentUsername?: string };
- if (resolved && resolved.user) {
- this.user = resolved.user;
- this.parentUsername = resolved.parentUsername;
- this.accountType = this.userSvc.getAccountType(this.user);
+ const user = (data[0] as User) || null;
+ if (user) {
+ this.user = user;
}
});
+ this.sub$.add(this.appActions.ofTypes([profileAction.UPDATE_SUCCESS]).subscribe(action => {
+ //TODO: Update current logged in user in app's state ???
+ }));
+
+ if (!this.authSvc.hasRole([RoleIds.ADMIN])) {
+ this.store.dispatch(new FetchSubPlans());
+ }
}
updateProfile() {
if (!this.form || !this.form.value || !this.form.valid) return;
+
+ // TODO: storing user profile in state ??
const userObj = Object.assign(this.selectedItem, this.form.value.profile, this.form.value.account);
this.store.dispatch(new profileAction.Update(userObj));
}
@@ -69,14 +72,6 @@ export class ProfileUpdateComponent extends BaseComp implements OnInit, OnDestro
this.router.navigate(['../']);
}
- getAccountTitle(): string {
- return this.authSvc.isApplicator ? globals.masterAcc : globals.subAcc.replace('#account#', this.parentUsername);
- }
-
- get isPartner(): boolean {
- return this.user?.kind === RoleIds.PARTNER;
- }
-
ngOnDestroy() {
super.ngOnDestroy();
}
diff --git a/Development/client/src/app/profile/usage-detail/usage-detail.component.html b/Development/client/src/app/profile/usage-detail/usage-detail.component.html
index f1ebb02..f8d8f49 100644
--- a/Development/client/src/app/profile/usage-detail/usage-detail.component.html
+++ b/Development/client/src/app/profile/usage-detail/usage-detail.component.html
@@ -3,7 +3,7 @@
To :
diff --git a/Development/client/src/app/profile/usage-detail/usage-detail.component.ts b/Development/client/src/app/profile/usage-detail/usage-detail.component.ts
index 935f16f..9e8e971 100644
--- a/Development/client/src/app/profile/usage-detail/usage-detail.component.ts
+++ b/Development/client/src/app/profile/usage-detail/usage-detail.component.ts
@@ -62,7 +62,7 @@ export class UsageDetailComponent implements OnInit, OnChanges {
this.cols = [
{ field: this.createdAt, header: $localize`:@@createdDate:Created Date`, width: "15%" },
{ field: "jobId", header: $localize`:@@jobId:Job Id`, width: "15%" },
- { field: this.ttSprArea, header: $localize`:@@ttArea:Total Area (acres)`, width: "20%" },
+ { field: this.ttSprArea, header: $localize`:@@ttArea:Total Area`, width: "20%" },
{ field: this.totalSprayed, header: $localize`:@@appliedAcres:Applied Acres`, width: "25%" },
{ field: this.updateDate, header: $localize`:@@appliedAcres:Last Applied Date`, width: "25%" }
];
@@ -103,10 +103,7 @@ export class UsageDetailComponent implements OnInit, OnChanges {
}
selPeriod(targetName?: string) {
- /*
- NOTE: Mar 16/2026, relax validation to allow dates outside of default range, as backend can handle it and will return empty data if out of bounds. This allows users to select any date range without being blocked by the UI validation, while still ensuring that the fromDate is not after the toDate.
- This can be revisited in the future if we want to add stricter validation based on the actual data range or other business rules such as min/max from subscription periods. */
- const validRange = !!this.fromDate && !!this.toDate && this.fromDate <= this.toDate /*&& this.fromDate >= this.defDateRange.minDate*/ && this.toDate <= this.defDateRange.maxDate;
+ const validRange = !!this.fromDate && !!this.toDate && this.fromDate <= this.toDate && this.fromDate >= this.defDateRange.minDate && this.toDate <= this.defDateRange.maxDate;
if (validRange) {
this.selPeriodEvt.emit({ fromTS: DateUtils.dateToTS(this.fromDate), toTS: DateUtils.dateToTS(this.toDate) });
} else {
diff --git a/Development/client/src/app/reducers/auth.reducer.ts b/Development/client/src/app/reducers/auth.reducer.ts
index 15f363e..9006f39 100644
--- a/Development/client/src/app/reducers/auth.reducer.ts
+++ b/Development/client/src/app/reducers/auth.reducer.ts
@@ -1,7 +1,6 @@
import { UserModel } from '../auth/models/user.model';
import * as actions from '../auth/actions/auth.actions';
-import * as subActions from '@app/actions/subscription.actions';
-import * as profileActions from '@app/profile/actions/profile.actions';
+import * as subActions from '@app/actions/subscription.actions'
export interface State {
user: UserModel | null;
@@ -11,28 +10,12 @@ export const initialState: State = {
user: null,
};
-export function reducer(state = initialState, action: actions.All | subActions.SubscriptionAction | profileActions.All): State {
+export function reducer(state = initialState, action: actions.All | subActions.SubscriptionAction): State {
switch (action.type) {
case actions.LOGIN_SUCCESS:
return { ...state, user: action.payload.user };
- case actions.REFRESH_USER_DATA:
- // Merge fresh user data from server, preserving fields not returned by getUser API
- const freshUser = action.payload.user;
- return {
- ...state,
- user: {
- ...state.user,
- username: freshUser.username,
- contact: freshUser.contact,
- name: freshUser.name,
- }
- };
case actions.LOGOUT_COMPLETE:
return initialState;
- case profileActions.UPDATE_SUCCESS:
- // Update only the fields that can change in profile: username, contact, and kind (account type)
- const { username, contact } = action.payload;
- return { ...state, user: { ...state.user, username, contact } };
case subActions.CONFIRM_ACTION_SUCCESS:
return { ...state, user: { ...state.user, membership: action.payload.membership } };
case subActions.CONFIRM_PAYMENT_SUCCESS:
@@ -41,24 +24,12 @@ export function reducer(state = initialState, action: actions.All | subActions.S
return { ...state, user: { ...state.user, membership: action.payload.membership } };
case subActions.UPDATE_SUBSCRIPTION_SUCCESS:
return { ...state, user: { ...state.user, membership: action.payload.membership } };
- case subActions.FETCH_LATEST_SUBSCRIPTION_SUCCESS: {
- const incoming = action.payload.membership;
- // Merge: keep existing subscriptions when the incoming membership doesn't carry them
- // (e.g. MembershipResolver on startup returns DB-level membership without subscriptions)
- const merged = {
- ...incoming,
- subscriptions: incoming?.subscriptions ?? state.user?.membership?.subscriptions,
- };
- return { ...state, user: { ...state.user, membership: merged } };
- }
+ case subActions.FETCH_LATEST_SUBSCRIPTION_SUCCESS:
+ return { ...state, user: { ...state.user, membership: action.payload.membership } };
case subActions.POLL_UNPAID_SUBSCRIPTION_SUCCESS:
return { ...state, user: { ...state.user, membership: action.payload.membership } };
case subActions.RESET_SUBSCRIPTION:
- // Do NOT clear membership.subscriptions here – auth subscription data is authoritative
- // and must only be updated via FETCH_LATEST_SUBSCRIPTION_SUCCESS (which carries confirmed
- // Stripe data). Clearing it here caused the expiry-warning banner to flash off whenever
- // manage-subscription dispatched InitSubscription (e.g. navigating to My Services).
- return state;
+ return { ...state, user: { ...state.user, membership: { ...state.user?.membership, endOfPeriod: void 0, subscriptions: [] } } };
case subActions.UPDATE_TRIAL:
return { ...state, user: { ...state.user, membership: { ...state.user.membership, trials: action.payload } } };
default:
diff --git a/Development/client/src/app/reducers/index.ts b/Development/client/src/app/reducers/index.ts
index 04a1d04..31ce767 100644
--- a/Development/client/src/app/reducers/index.ts
+++ b/Development/client/src/app/reducers/index.ts
@@ -7,12 +7,9 @@ import * as fromLogin from './login.reducer';
import * as fromSubPlans from './sub-plans.reducer';
import * as fromSubs from './subscription.reducer';
import * as fromSubIntent from './subscription-intent.reducer';
-import { SubLimit, SubscriptionIntent, Unpaid, ExpiryWarning } from '@app/domain/models/subscription.model';
+import { SubLimit, SubscriptionIntent, Unpaid } from '@app/domain/models/subscription.model';
import { UserModel } from '@app/auth/models/user.model';
-import { SubType, SUB_NAME, SubStripe } from '@app/profile/common';
-
-// ExpiryWarning type constants
-const EXPIRY_TYPE_BOTH = 'both';
+import { SubType } from '@app/profile/common';
export interface State {
auth: fromAuth.State;
@@ -51,10 +48,6 @@ export function sessionStorageSyncReducer(reducer: ActionReducer
): ActionRe
storage: sessionStorage,
keys:
[ //Specify list of state needs to be rehydrated after page reloaded
- // Persist full auth state including subscriptions so the expiry-warning banner
- // survives F5/reload. The auth reducer merges incoming membership data so that
- // a FETCH_LATEST_SUBSCRIPTION_SUCCESS without subscriptions (e.g. from the
- // MembershipResolver on startup) does not wipe out the persisted subscriptions.
'auth',
'Entities',
'Clients',
@@ -69,7 +62,7 @@ export function sessionStorageSyncReducer(reducer: ActionReducer): ActionRe
'subscription',
'subIntent'
],
- rehydrate: true
+ rehydrate: true
})(reducer);
}
@@ -111,160 +104,19 @@ export const selectUserSubscriptions = createSelector(
(membership) => membership?.subscriptions
);
-export const selectUserCustomLimits = createSelector(
- selectUserMembership,
- (membership) => membership?.customLimits
-);
-
-/**
- * Select subscription expiry warning
- * Returns warning details if subscription expires in 1-7 days
- * Checks both package and addon subscriptions with individual expiry dates
- */
-export const selectExpiryWarning = createSelector(
- selectUserSubscriptions,
- (subscriptions): ExpiryWarning | null => {
- if (!subscriptions || subscriptions.length === 0) {
- return null;
- }
-
- const now = Math.floor(Date.now() / 1000);
- const expiringItems: {
- package?: any;
- addons: any[];
- earliestExpiry: number;
- } = {
- addons: [],
- earliestExpiry: Infinity
- };
-
- // Check package subscription
- const packageSub = subscriptions.find(sub => sub.type === SubType.PACKAGE);
- if (packageSub && packageSub.periodEnd) {
- const daysUntilExpiry = Math.floor((packageSub.periodEnd - now) / 86400);
- if (daysUntilExpiry >= 0 && daysUntilExpiry <= environment.expiryWarningDays) {
- const lookupKey = packageSub.items?.[0]?.price as string;
- expiringItems.package = {
- subscription: packageSub,
- daysUntilExpiry,
- lookupKey
- };
- expiringItems.earliestExpiry = Math.min(expiringItems.earliestExpiry, packageSub.periodEnd);
- }
- }
-
- // Check addon subscriptions
- const addonSubs = subscriptions.filter(sub => sub.type === SubType.ADDON);
- addonSubs.forEach(addon => {
- if (addon.periodEnd) {
- const daysUntilExpiry = Math.floor((addon.periodEnd - now) / 86400);
- if (daysUntilExpiry >= 0 && daysUntilExpiry <= environment.expiryWarningDays) {
- const lookupKey = addon.items?.[0]?.price as string;
- expiringItems.addons.push({
- subscription: addon,
- daysUntilExpiry,
- lookupKey
- });
- expiringItems.earliestExpiry = Math.min(expiringItems.earliestExpiry, addon.periodEnd);
- }
- }
- });
-
- // Return null if nothing is expiring
- if (!expiringItems.package && expiringItems.addons.length === 0) {
- return null;
- }
-
- // Use the earliest expiry date for the warning
- const daysUntilExpiry = Math.floor((expiringItems.earliestExpiry - now) / 86400);
-
- // Determine subscription type and details
- const hasPackage = !!expiringItems.package;
- const hasAddons = expiringItems.addons.length > 0;
- const primarySub = expiringItems.package?.subscription || expiringItems.addons[0].subscription;
-
- // Build package details
- const packageDetails = expiringItems.package ? {
- name: SUB_NAME[expiringItems.package.lookupKey] || expiringItems.package.lookupKey,
- lookupKey: expiringItems.package.lookupKey,
- daysUntilExpiry: expiringItems.package.daysUntilExpiry,
- periodEnd: expiringItems.package.subscription.periodEnd,
- willAutoRenew: !expiringItems.package.subscription.cancelAtPeriodEnd,
- isTrial: expiringItems.package.subscription.status === SubStripe.TRIALING,
- isCanceled: expiringItems.package.subscription.status === SubStripe.CANCELED
- } : undefined;
-
- // Build addon details
- const addonDetails = expiringItems.addons.map(addon => ({
- name: SUB_NAME[addon.lookupKey] || addon.lookupKey,
- lookupKey: addon.lookupKey,
- daysUntilExpiry: addon.daysUntilExpiry,
- periodEnd: addon.subscription.periodEnd,
- willAutoRenew: !addon.subscription.cancelAtPeriodEnd,
- isTrial: addon.subscription.status === SubStripe.TRIALING,
- isCanceled: addon.subscription.status === SubStripe.CANCELED
- }));
-
- return {
- id: primarySub.id,
- type: hasPackage && hasAddons ? EXPIRY_TYPE_BOTH : hasPackage ? SubType.PACKAGE : SubType.ADDON,
- status: primarySub.status,
- daysUntilExpiry,
- cancelAtPeriodEnd: primarySub.cancelAtPeriodEnd,
- periodEnd: expiringItems.earliestExpiry,
- isTrial: primarySub.status === SubStripe.TRIALING,
- willAutoRenew: !primarySub.cancelAtPeriodEnd,
- package: packageDetails,
- addons: addonDetails.length > 0 ? addonDetails : undefined
- };
- }
-);
-
-/**
- * Returns a warning for sub-accounts that have no active or trialing subscriptions.
- */
-export const selectNoSubsWarning = createSelector(
- selectAuthUser,
- selectUserSubscriptions,
- (user, subscriptions): ExpiryWarning | null => {
- if (!user?.parent || user.parent === user._id) return null;
- const hasActiveSub = subscriptions?.some(
- sub => sub.status === SubStripe.ACTIVE || sub.status === SubStripe.TRIALING
- );
- if (hasActiveSub) return null;
- return {
- id: '', type: 'package', status: '', daysUntilExpiry: 0,
- cancelAtPeriodEnd: false, periodEnd: 0,
- isTrial: false, willAutoRenew: false, noSubs: true
- };
- }
-);
-
export const selectSubPkgs = createSelector(
selectUserSubscriptions,
- (subs) => {
- // Find latest package subscription by periodEnd
- const pkgSubs = subs?.filter(sub => sub.type === SubType.PACKAGE);
- if (!pkgSubs || pkgSubs.length === 0) return [];
-
- const latestPkg = pkgSubs.reduce((acc, curr) =>
- (curr.periodEnd > acc.periodEnd) ? curr : acc, pkgSubs[0]
- );
-
- const result = [{
- id: latestPkg.id,
- lookupKey: latestPkg.items?.[0]?.price,
- status: latestPkg.status,
- periodEnd: latestPkg.periodEnd,
- cancelAtPeriodEnd: latestPkg.cancelAtPeriodEnd,
- quantity: latestPkg.items?.[0]?.quantity,
- paymentMethod: '',
- trialEnd: latestPkg.trial_end,
- promoDetails: latestPkg.promoDetails
- }];
-
- return result;
- }
+ (subs) => subs?.filter(((sub) => sub.type === SubType.PACKAGE))
+ .map((sub) => ({
+ id: sub.id,
+ lookupKey: sub.items?.[0]?.price,
+ status: sub.status,
+ periodEnd: sub.periodEnd,
+ cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
+ quantity: sub.items?.[0]?.quantity,
+ paymentMethod: ''
+ })
+ )
);
export const selectSubAddons = createSelector(
@@ -277,11 +129,9 @@ export const selectSubAddons = createSelector(
periodEnd: sub.periodEnd,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd,
quantity: sub.items?.[0]?.quantity,
- paymentMethod: '',
- trialEnd: sub.trial_end, // Added for Case 2C trial promo display
- promoDetails: sub.promoDetails // Added for Case 2C trial promo display
+ paymentMethod: ''
})
- )
+ )
);
// subscription
@@ -343,16 +193,6 @@ export const selectSubPlansStatus = createSelector(
(state: fromSubPlans.State) => state.status
);
-export const selectSubPlansLoading = createSelector(
- selectSubPlansState,
- (state: fromSubPlans.State) => state.loading
-);
-
-export const selectSubPlansLoaded = createSelector(
- selectSubPlansState,
- (state: fromSubPlans.State) => state.loaded
-);
-
// subIntent
export const getSubIntentState = createFeatureSelector('subIntent');
@@ -370,7 +210,7 @@ export const getSubIntentStatus = createSelector(
export const getRefreshSubIntent = createSelector(
selectAuthUser,
getSubIntentState,
- (user: UserModel, subIntent: fromSubIntent.State) =>
+ (user: UserModel, subIntent: fromSubIntent.State) =>
({
applicatorId: user?._id,
custId: user.membership?.custId,
@@ -390,7 +230,7 @@ export const getSubIntentPkgCoupons = createSelector(
(state: SubscriptionIntent) => state?.coupons
);
-export const getSubIntentMode = createSelector(
+export const getSubIntentMode = createSelector(
getSubIntentState,
(state: fromSubIntent.State) => state?.mode
);
diff --git a/Development/client/src/app/reducers/sub-plans.reducer.ts b/Development/client/src/app/reducers/sub-plans.reducer.ts
index 60e557e..0a650f0 100644
--- a/Development/client/src/app/reducers/sub-plans.reducer.ts
+++ b/Development/client/src/app/reducers/sub-plans.reducer.ts
@@ -6,37 +6,31 @@ import * as subPlansActions from '@app/actions/sub-plans.actions'
export interface State {
subLimit: SubLimit;
status: Status;
- loading: boolean; // Track loading state for skeleton UI
- loaded: boolean; // Track if data has been loaded at least once
}
const initialState: State = {
subLimit: void 0,
- status: void 0,
- loading: false,
- loaded: false
+ status: void 0
}
export function reducer(state = initialState, action: authActions.All | subActions.SubscriptionAction | subPlansActions.SubPlansAction): State {
switch (action.type) {
- case subPlansActions.FETCH_SUB_PLANS:
- return { ...state, loading: true, status: void 0 };
case subActions.CONFIRM_ACTION_SUCCESS:
- return { ...state, subLimit: getLimit(action.payload), loading: false, loaded: true };
+ return { ...state, subLimit: getLimit(action.payload) };
case subActions.CONFIRM_PAYMENT_SUCCESS:
- return { ...state, subLimit: getLimit(action.payload), loading: false, loaded: true };
+ return { ...state, subLimit: getLimit(action.payload) };
case subActions.PAY_UNPAID_SUBSCRIPTION_SUCCESS:
- return { ...state, subLimit: getLimit(action.payload), loading: false, loaded: true };
+ return { ...state, subLimit: getLimit(action.payload) };
case subActions.UPDATE_SUBSCRIPTION_SUCCESS:
- return { ...state, subLimit: getLimit(action.payload), loading: false, loaded: true };
+ return { ...state, subLimit: getLimit(action.payload) };
case subPlansActions.FETCH_SUB_PLANS_SUCCESS:
const newSubLimit = getLimit(action.payload);
if (JSON.stringify(state.subLimit) === JSON.stringify(newSubLimit)) {
- return { ...state, loading: false, loaded: true };
+ return state;
}
- return { ...state, subLimit: newSubLimit, status: void 0, loading: false, loaded: true };
+ return { ...state, subLimit: newSubLimit, status: void 0 };
case subPlansActions.FETCH_SUB_PLANS_FAILED:
- return { ...state, status: action.payload, loading: false };
+ return { ...state, status: action.payload };
case subPlansActions.RESET_SUB_PLANS:
return initialState;
default:
diff --git a/Development/client/src/app/reducers/subscription-intent.reducer.ts b/Development/client/src/app/reducers/subscription-intent.reducer.ts
index 7e17247..483f8c6 100644
--- a/Development/client/src/app/reducers/subscription-intent.reducer.ts
+++ b/Development/client/src/app/reducers/subscription-intent.reducer.ts
@@ -31,15 +31,12 @@ export function reducer(state: State = initialState, action: actions.Subscriptio
case actions.CHECK_OUT:
return { ...state, package: { ...state.package, card: action.payload }, prevStage: SUB.CHKOUT, stage: SUB.CHKOUT_REV };
case actions.CHECK_OUT_TRIAL_SUCCESS:
- return {
- ...state, package: {
- ...state.package,
- card: action.payload.card,
- selAddons: state.package?.selAddons?.map((addon) => ({ ...addon, trialEnd: getTrialEnd(action.payload.subs, addon.lookupKey, addon.trialEnd) })) || [],
- selPkg: state.package?.selPkg ? { ...state.package.selPkg, trialEnd: getTrialEnd(action.payload.subs, state.package.selPkg.lookupKey, state.package.selPkg.trialEnd) } : null, amount: action.payload.amount
- },
- prevStage: SUB.CHKOUT,
- stage: SUB.CHKOUT_CONF
+ return { ...state, package: { ...state.package,
+ card: action.payload.card,
+ selAddons: state.package?.selAddons?.map((addon) => ({ ...addon, trialEnd: getTrialEnd(action.payload.subs, addon.lookupKey, addon.trialEnd) })) || [],
+ selPkg: state.package?.selPkg ? { ...state.package.selPkg, trialEnd: getTrialEnd(action.payload.subs, state.package.selPkg.lookupKey, state.package.selPkg.trialEnd) } : null, amount: action.payload.amount },
+ prevStage: SUB.CHKOUT,
+ stage: SUB.CHKOUT_CONF
};
case actions.CLEAR_SUBSCRIPTION_INTENT_STATUS:
return { ...state, status: void 0 };
@@ -73,8 +70,6 @@ export function reducer(state: State = initialState, action: actions.Subscriptio
return { ...state, status: action.payload };
case actions.UPDATE_AMOUNT:
return { ...state, package: { ...state.package, amount: action.payload } };
- case actions.UPDATE_PROMO_SAVINGS:
- return { ...state, package: { ...state.package, promoSavings: action.payload } };
case actions.START_CHECKOUT_SUCCESS:
return { ...state, package: action.payload, status: void 0, stage: SUB.CHKOUT };
case actions.LOAD_STRIPE_FAILED:
diff --git a/Development/client/src/app/report.component.ts b/Development/client/src/app/report.component.ts
index 192e013..0906da3 100644
--- a/Development/client/src/app/report.component.ts
+++ b/Development/client/src/app/report.component.ts
@@ -6,7 +6,6 @@ import { environment } from '@environments/environment';
import { JobService } from '@app/domain/services/job.service';
import { globals, locales } from '@app/shared/global';
import { RSLoaderService } from '@app/domain/services/rsloader.service';
-import { GAService } from '@app/shared/ga.service';
declare var Stimulsoft: any;
@@ -24,16 +23,13 @@ export class ReportComponent implements OnInit, OnDestroy {
designer: any;
locale: string = 'en';
rid: string;
- private reportViewStartTime: number;
- private reportType: 'job_summary' | 'financial_analysis' | 'field_report' | 'performance_dashboard';
constructor(
private readonly route: ActivatedRoute,
private readonly zone: NgZone,
private readonly jobSvc: JobService,
private readonly rsLoaderSvc: RSLoaderService,
- private readonly title: Title,
- private readonly gaSvc: GAService
+ private readonly title: Title
) {
this.title.setTitle("AgMission - Report");
}
@@ -137,15 +133,8 @@ export class ReportComponent implements OnInit, OnDestroy {
this.viewer = new Stimulsoft.Viewer.StiViewer(options, 'StiViewer', false);
- this.viewer.onPrintReport = (event) => {
+ this.viewer.onPrintReport = function (event) {
if (!environment.production) console.log(event);
-
- // Track report export/print
- this.gaSvc.trackReportExported({
- report_type: this.reportType,
- export_format: 'pdf',
- platform: 'web'
- });
}
// Add the design button event
@@ -155,14 +144,6 @@ export class ReportComponent implements OnInit, OnDestroy {
this.viewer.onInteraction = (args, cb) => {
if (args.action === "Variables") {
- // Track report parameter interaction
- this.gaSvc.trackReportFiltered({
- report_type: this.reportType || 'unknown',
- report_id: this.rid,
- filter_applied: 'parameters',
- platform: 'web'
- });
-
// Save parameters to BE
const vars = args.variables;
delete vars['yes'];
@@ -181,13 +162,6 @@ export class ReportComponent implements OnInit, OnDestroy {
private designReport(report) {
if (!report) return;
- // Track report design mode entry
- this.gaSvc.trackReportDesignModeEntered({
- report_type: this.reportType || 'unknown',
- report_id: this.rid,
- platform: 'web'
- });
-
this.viewer.visible = false;
if (!this.designer) {
this.rsLoaderSvc.loadRptDesigner().subscribe(
@@ -223,23 +197,11 @@ export class ReportComponent implements OnInit, OnDestroy {
const lang = this.route.snapshot.queryParams["lang"];
this.locale = lang || 'en';
- // Detect report type from rid or path for better analytics
- this.reportType = this.detectReportType(this.rid, path);
-
if (!this.viewer)
this.initViewer.call(this, isVar);
this.viewer.showProcessIndicator();
- // Track report generation start
- const generationStartTime = Date.now();
- this.gaSvc.trackReportGenerated({
- report_type: this.reportType,
- date_range_days: 30, // Default assumption - could be enhanced
- jobs_included: 0, // Will be updated if we can determine this
- platform: 'web'
- });
-
var report = rptObj;
if (reload || !rptObj) {
report = new Stimulsoft.Report.StiReport();
@@ -287,86 +249,17 @@ export class ReportComponent implements OnInit, OnDestroy {
}
private renderReport(report) {
- const renderStartTime = Date.now();
-
report.renderAsync(() => {
- const renderDuration = Date.now() - renderStartTime;
-
if (this.designer && this.designer.visible) { // Refresh the report if the being in Design mode
this.designer.report = report;
this.designer.renderHtml("designerContent");
}
this.viewer.report = report;
this.viewer.renderHtml("viewerContent");
-
- // Track report viewing
- this.reportViewStartTime = Date.now();
- this.gaSvc.trackReportViewed({
- report_id: this.rid,
- report_type: this.reportType,
- platform: 'web'
- });
-
- // Track report generation performance
- this.gaSvc.trackReportRendered({
- report_type: this.reportType || 'unknown',
- report_id: this.rid,
- render_duration_ms: renderDuration,
- platform: 'web'
- });
});
}
ngOnDestroy() {
- // Track report view duration if available
- if (this.reportViewStartTime) {
- const viewDuration = Math.round((Date.now() - this.reportViewStartTime) / 1000);
- this.gaSvc.trackReportViewDuration({
- report_type: this.reportType || 'unknown',
- report_id: this.rid,
- view_duration_seconds: viewDuration,
- platform: 'web'
- });
- }
- }
- /**
- * Detect report type from report ID or path for better analytics
- */
- private detectReportType(rid: string, path: string): 'job_summary' | 'financial_analysis' | 'field_report' | 'performance_dashboard' {
- // Map report IDs or paths to report types
- const reportTypeMap: { [key: string]: 'job_summary' | 'financial_analysis' | 'field_report' | 'performance_dashboard' } = {
- 'job': 'job_summary',
- 'financial': 'financial_analysis',
- 'field': 'field_report',
- 'performance': 'performance_dashboard',
- 'summary': 'job_summary',
- 'analysis': 'financial_analysis',
- 'report': 'field_report',
- 'dashboard': 'performance_dashboard'
- };
-
- // Check rid first
- if (rid) {
- const ridLower = rid.toLowerCase();
- for (const [key, value] of Object.entries(reportTypeMap)) {
- if (ridLower.includes(key)) {
- return value;
- }
- }
- }
-
- // Check path
- if (path) {
- const pathLower = path.toLowerCase();
- for (const [key, value] of Object.entries(reportTypeMap)) {
- if (pathLower.includes(key)) {
- return value;
- }
- }
- }
-
- // Default to job_summary if unable to determine
- return 'job_summary';
}
}
diff --git a/Development/client/src/app/settings/settings-routing.module.ts b/Development/client/src/app/settings/settings-routing.module.ts
deleted file mode 100644
index bf57b2e..0000000
--- a/Development/client/src/app/settings/settings-routing.module.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { NgModule } from '@angular/core';
-import { Routes, RouterModule } from '@angular/router';
-
-import { AuthGuard } from '../domain/guards/auth.guard';
-import { RoleIds } from '../shared/global';
-import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component';
-
-const routes: Routes = [
- {
- path: '',
- redirectTo: 'subscription',
- pathMatch: 'full'
- },
- {
- path: 'subscription',
- component: SubscriptionMgtComponent,
- data: {
- roles: [RoleIds.ADMIN]
- },
- canActivate: [AuthGuard]
- }
-];
-
-@NgModule({
- imports: [RouterModule.forChild(routes)],
- exports: [RouterModule]
-})
-export class SettingsRoutingModule { }
diff --git a/Development/client/src/app/settings/settings.module.ts b/Development/client/src/app/settings/settings.module.ts
deleted file mode 100644
index 8ef7281..0000000
--- a/Development/client/src/app/settings/settings.module.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormsModule, ReactiveFormsModule } from '@angular/forms';
-import { HttpClientModule } from '@angular/common/http';
-
-import { SettingsRoutingModule } from './settings-routing.module';
-import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component';
-import { AppSharedModule } from '@app/shared/app-shared.module';
-
-// PrimeNG Modules
-import { AccordionModule } from 'primeng/accordion';
-import { ButtonModule } from 'primeng/button';
-import { DropdownModule } from 'primeng/dropdown';
-import { CalendarModule } from 'primeng/calendar';
-import { InputTextModule } from 'primeng/inputtext';
-import { InputNumberModule } from 'primeng/inputnumber';
-import { PanelModule } from 'primeng/panel';
-import { TableModule } from 'primeng/table';
-import { DialogModule } from 'primeng/dialog';
-import { TooltipModule } from 'primeng/tooltip';
-import { MessageModule } from 'primeng/message';
-import { MessagesModule } from 'primeng/messages';
-import { ProgressSpinnerModule } from 'primeng/progressspinner';
-
-@NgModule({
- declarations: [
- SubscriptionMgtComponent
- ],
- imports: [
- CommonModule,
- FormsModule,
- ReactiveFormsModule,
- HttpClientModule,
- SettingsRoutingModule,
- AppSharedModule,
- // PrimeNG
- AccordionModule,
- ButtonModule,
- DropdownModule,
- CalendarModule,
- InputTextModule,
- InputNumberModule,
- PanelModule,
- TableModule,
- DialogModule,
- TooltipModule,
- MessageModule,
- MessagesModule,
- ProgressSpinnerModule
- ],
- schemas: [CUSTOM_ELEMENTS_SCHEMA]
-})
-export class SettingsModule { }
diff --git a/Development/client/src/app/settings/subscription/promo.service.ts b/Development/client/src/app/settings/subscription/promo.service.ts
deleted file mode 100644
index 915a51a..0000000
--- a/Development/client/src/app/settings/subscription/promo.service.ts
+++ /dev/null
@@ -1,265 +0,0 @@
-import { Injectable } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
-import { Observable } from 'rxjs';
-import { map } from 'rxjs/operators';
-
-// ============================================================================
-// INTERFACES
-// ============================================================================
-
-/**
- * Represents a subscription promo from the backend API
- */
-export interface Promo {
- _id: string;
- type: 'package' | 'addon' | 'all';
- priceKey: string; // e.g., 'ess_1', 'addon_1', 'all'
- enabled: boolean;
- validUntil: string; // ISO date
- couponId: string; // Stripe coupon ID
- name: string; // Display name
- nameKey?: string; // i18n key (auto-generated)
- descriptionKey?: string; // i18n key (auto-generated)
- discountType: 'free' | 'percent' | 'fixed';
- discountValue: number; // 100 for free, 50 for 50%
- usageCount: number; // Number of subscriptions using this promo
- createdAt: string; // ISO date
- /** Who is eligible to redeem this promo. 'all' = everyone, 'new_only' = new customers only, 'renew_only' = existing customers renewing only */
- eligibility?: 'all' | 'new_only' | 'renew_only';
-}
-
-/**
- * Response structure from /api/admin/subscriptionPromos (r949+)
- * Backend returns both promos array and current PROMO_MODE info
- */
-export interface AdminPromoResponse {
- promos: Promo[];
- currentMode: {
- mode: 'enabled' | 'disabled';
- isActive: boolean;
- description: string;
- behavior: {
- newSubscriptions: boolean;
- renewals: boolean;
- activeAutoRenewal: boolean;
- };
- };
-}
-
-/**
- * Request payload for creating a new promo
- */
-export interface CreatePromoRequest {
- type: 'package' | 'addon' | 'all';
- priceKey: string;
- validUntil: string;
- couponId: string;
- discountType: 'free' | 'percent' | 'fixed';
- discountValue: number;
- name: string;
- enabled?: boolean;
- /** Who is eligible to redeem this promo. Default: 'all' */
- eligibility?: 'all' | 'new_only' | 'renew_only';
-}
-
-/**
- * Response from DELETE promo endpoint
- */
-export interface DeletePromoResponse {
- action: 'deleted' | 'disabled';
- promo: Promo;
- schedulesUpdated?: number;
- schedulesFailed?: number;
-}
-
-/**
- * Request payload for updating a promo
- */
-export interface UpdatePromoRequest {
- validUntil?: string;
- name?: string;
-}
-
-/**
- * Response from PUT promo endpoint
- */
-export interface UpdatePromoResponse {
- action: 'updated';
- promo: Partial;
- schedulesUpdated?: number;
- schedulesFailed?: number;
-}
-
-/**
- * Represents a Stripe coupon option for dropdown
- * Matches backend response from /api/admin/subscriptionPromos/coupons
- */
-export interface StripeCoupon {
- id: string;
- name: string;
- percent_off?: number; // Percentage discount (e.g., 50 for 50% off)
- amount_off?: number; // Fixed amount discount in cents
- currency?: string; // Currency for amount_off
- duration: string; // 'forever', 'once', 'repeating'
- duration_in_months?: number; // Number of months for repeating coupons
- valid: boolean; // Whether coupon is valid
- created: number; // Unix timestamp
-}
-
-// ============================================================================
-// SERVICE DEFINITION
-// ============================================================================
-
-// Valid enum values (must match backend Mongoose schema)
-// Note: Empty string '' is also valid for universal promos (backend requirement)
-const VALID_TYPES = ['package', 'addon', ''] as const;
-const VALID_DISCOUNT_TYPES = ['free', 'percent', 'fixed'] as const;
-
-@Injectable({
- providedIn: 'root'
-})
-export class PromoService {
-
- private readonly baseUrl = '/admin/subscriptionPromos';
-
- constructor(private readonly http: HttpClient) { }
-
- // ============================================================================
- // VALIDATION HELPERS
- // ============================================================================
-
- /**
- * Validates and sanitizes promo request before sending to backend.
- * Note: Empty string '' is valid for universal promos (backend requirement)
- */
- private validateAndSanitizeRequest(request: CreatePromoRequest): CreatePromoRequest {
- const errors: string[] = [];
-
- // Validate type - allow empty string for universal promos
- // Type widening: Use string[] to properly include empty string in .includes() check
- const validTypes: string[] = ['package', 'addon', ''];
- if (!validTypes.includes(request.type)) {
- errors.push(`Invalid type "${request.type}". Must be: package, addon, (empty string for universal)`);
- }
-
- // Validate discountType - default to 'free' if invalid/missing
- if (!request.discountType || !VALID_DISCOUNT_TYPES.includes(request.discountType as any)) {
- console.warn(`PromoService: Invalid discountType "${request.discountType}", defaulting to "free"`);
- request = { ...request, discountType: 'free' };
- }
-
- // Validate discountValue - default to 100 if invalid/missing
- if (request.discountValue === undefined || request.discountValue === null || isNaN(request.discountValue)) {
- console.warn(`PromoService: Invalid discountValue "${request.discountValue}", defaulting to 100`);
- request = { ...request, discountValue: 100 };
- }
-
- // Throw error for critical validation failures
- if (errors.length > 0) {
- throw new Error(`Validation failed: ${errors.join('; ')}`);
- }
-
- return request;
- }
-
- // ============================================================================
- // API METHODS
- // ============================================================================
-
- /**
- * GET /api/admin/subscriptionPromos
- * Returns all promo rules with full details
- *
- * @since r949 - Backend returns {promos, currentMode} object
- */
- getPromos(): Observable {
- return this.http.get(this.baseUrl).pipe(
- map(response => response.promos)
- );
- }
-
- /**
- * GET /api/admin/subscriptionPromos - Returns current PROMO_MODE status
- *
- * @returns Observable of current mode information
- * @since r949
- *
- * @example
- * ```typescript
- * this.promoService.getCurrentMode().subscribe(mode => {
- * console.log(`Current mode: ${mode.mode}`);
- * console.log(`Active: ${mode.isActive}`);
- * console.log(`Description: ${mode.description}`);
- * });
- * ```
- */
- getCurrentMode(): Observable {
- return this.http.get(this.baseUrl).pipe(
- map(response => response.currentMode)
- );
- }
-
- /**
- * POST /api/admin/subscriptionPromos/add
- * Add a single promo rule
- *
- * NOTE: Includes frontend validation to protect against backend bug
- * that accepts invalid enum values for discountType/type fields.
- *
- * @since r949 - Backend returns {promos, currentMode} object
- */
- addPromo(request: CreatePromoRequest): Observable {
- // Validate and sanitize before sending to backend
- const sanitizedRequest = this.validateAndSanitizeRequest(request);
- return this.http.post(`${this.baseUrl}/add`, sanitizedRequest).pipe(
- map(response => response.promos)
- );
- }
-
- /**
- * PUT /api/admin/subscriptionPromos/:id
- * Update a promo rule (only validUntil is editable)
- * Backend returns { action, promo, schedulesUpdated?, schedulesFailed? }
- */
- updatePromo(id: string, request: UpdatePromoRequest): Observable> {
- return this.http.put(`${this.baseUrl}/${id}`, request).pipe(
- map(response => response.promo)
- );
- }
-
- /**
- * DELETE /api/admin/subscriptionPromos/:id
- * Delete or disable a promo rule
- * - If usageCount === 0: Permanently deletes
- * - If usageCount > 0: Requires validUntil, disables instead
- */
- deletePromo(id: string, validUntil?: string): Observable {
- const url = `${this.baseUrl}/${id}`;
- if (validUntil) {
- // Use request method to send body with DELETE
- return this.http.request('DELETE', url, { body: { validUntil } });
- }
- return this.http.delete(url);
- }
-
- /**
- * PUT /api/admin/subscriptionPromos/:id — re-activates a promo.
- * Sends enabled=true AND a new validUntil so isActive() passes both checks.
- * Response shape: { action, promo: { _id, name, nameKey, validUntil, enabled, usageCount } }
- */
- activatePromo(id: string, validUntil: Date): Observable {
- return this.http.put<{ promo: Promo }>(`${this.baseUrl}/${id}`, {
- enabled: true,
- validUntil: validUntil.toISOString()
- }).pipe(map(res => res.promo));
- }
-
- /**
- * GET /admin/subscriptionPromos/coupons
- * Fetch available Stripe coupons with 'forever' duration for dropdown
- * Backend enforces 'forever' duration only (added in r934)
- */
- getAvailableCoupons(): Observable {
- return this.http.get(`${this.baseUrl}/coupons`);
- }
-}
diff --git a/Development/client/src/app/settings/subscription/subscription-mgt.component.css b/Development/client/src/app/settings/subscription/subscription-mgt.component.css
deleted file mode 100644
index 0b9e91c..0000000
--- a/Development/client/src/app/settings/subscription/subscription-mgt.component.css
+++ /dev/null
@@ -1,694 +0,0 @@
-/* ============================================================================
- SUBSCRIPTION PROMO MANAGEMENT COMPONENT STYLES
- Following AgMission Style Guide (constraint-message.component.css reference)
- ============================================================================ */
-
-/* Container */
-.subscription-mgt-container {
- padding: 16px 24px;
- max-width: 1200px;
- margin: 0 auto;
-}
-
-/* ============================================================================
- PAGE HEADER
- ============================================================================ */
-
-.page-header {
- margin-bottom: 24px;
-}
-
-.page-header h2 {
- color: #212121;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- font-size: 1.5rem;
- font-weight: 500;
- margin: 0 0 8px 0;
- letter-spacing: 0.25px;
-}
-
-.page-description {
- color: #757575;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- font-size: 0.875rem;
- margin: 0;
- line-height: 1.5;
-}
-
-/* ============================================================================
- PANEL STYLES (SHARED)
- ============================================================================ */
-
-/* Panel margins, form control widths, table font, dialog sizes, and calendar widths
- are all applied via PrimeNG styleClass to elements inside PrimeNG templates.
- They cannot be reached from scoped component CSS — see styles.scss for these rules. */
-
-
-.panel-header {
- display: flex;
- align-items: center;
- gap: 10px;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- font-weight: 500;
- color: #212121;
-}
-
-.panel-header i {
- font-size: 1.125rem;
- color: #4CAF50;
-}
-
-.promo-count {
- color: #757575;
- font-weight: 400;
- font-size: 0.875rem;
- margin-left: 4px;
-}
-
-/* ============================================================================
- CREATE FORM - TOP PANEL
- ============================================================================ */
-
-.create-form {
- padding: 16px;
-}
-
-.form-row {
- display: flex;
- flex-wrap: wrap;
- gap: 16px;
- margin-bottom: 16px;
-}
-
-.form-row:last-child {
- margin-bottom: 0;
-}
-
-.form-field {
- flex: 1;
- min-width: 150px;
- display: flex;
- flex-direction: column;
- gap: 6px;
- position: relative;
- padding-bottom: 18px;
- /* Reserve space for error message */
-}
-
-.form-field label {
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- font-size: 0.75rem;
- font-weight: 500;
- color: #757575;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.form-field-wide {
- flex: 2;
- min-width: 250px;
-}
-
-/* Group wrapper — always breaks to its own row so Type/Package/Coupon stay on row 1
- and Promo Name + Valid Until + Add button occupy row 2 */
-.form-field-group {
- display: flex;
- gap: 16px;
- align-items: flex-end;
- flex: 0 0 100%;
-}
-
-.form-field-group .form-field {
- flex: 1;
- min-width: 120px;
-}
-
-.form-field-group .form-field-button {
- flex: 0 0 auto;
- padding-top: 0;
-}
-
-.form-field-button {
- flex: 0 0 auto;
- min-width: auto;
- justify-content: flex-end;
- padding-top: 22px;
-}
-
-/* Promo name text input in create form row */
-.promo-name-input {
- width: 100%;
- box-sizing: border-box;
-}
-
-/* Promo name text input in edit row */
-.edit-promo-name-input {
- width: 200px;
- box-sizing: border-box;
- font-size: 0.8125rem;
-}
-
-/* PrimeNG p-dropdown and p-calendar internals are in styles.scss under body .form-dropdown / body .form-calendar */
-
-.field-error {
- color: #F44336;
- font-size: 0.75rem;
- position: absolute;
- bottom: 0;
- left: 0;
-}
-
-.field-help {
- color: #757575;
- font-size: 0.75rem;
- position: absolute;
- bottom: 0;
- left: 0;
-}
-
-/* Add button - match standard PrimeNG button sizing */
-.add-btn {
- min-width: 100px;
-}
-
-/* ============================================================================
- PREVIEW AREA
- ============================================================================ */
-
-.preview-area {
- margin-top: 16px;
-}
-
-/* Card — matches compact-vertical-card from manage-subscription */
-.preview-card {
- background: linear-gradient(135deg, #f0f9f0 0%, #ffffff 100%);
- border: 2px solid #4CAF50;
- border-radius: 6px;
- padding: 16px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-/* Header row: promo name left, Preview pill right */
-.preview-cv-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 10px;
-}
-
-.preview-cv-package-info {
- display: flex;
- align-items: center;
- /* gap: 8px; */
-}
-
-.preview-cv-icon {
- font-size: 1rem;
- color: #4CAF50;
- flex-shrink: 0;
-}
-
-.preview-cv-name {
- font-size: 1rem;
- font-weight: 600;
- color: #212121;
- letter-spacing: 0.15px;
-}
-
-/* Muted pill badge matching AgMission badge style */
-.preview-pill {
- display: inline-flex;
- align-items: center;
- padding: 2px 10px;
- border-radius: 12px;
- font-size: 0.6875rem;
- font-weight: 600;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- background: #A5D6A7;
- color: #1B5E20;
- border: 1px solid #4CAF50;
- white-space: nowrap;
- flex-shrink: 0;
-}
-
-/* Divider — identical to cv-divider in manage-subscription */
-.preview-cv-divider {
- height: 1px;
- background: #E0E0E0;
- margin: 0 0 10px 0;
-}
-
-/* Section rows — identical to cv-row / cv-label / cv-value pattern */
-.preview-cv-section {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding-left: 1em;
-}
-
-.preview-cv-row {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- font-size: 0.875rem;
- line-height: 1.4;
-}
-
-.preview-cv-label {
- color: #757575;
- font-weight: 500;
- flex-shrink: 0;
- margin-right: 12px;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
-}
-
-.preview-cv-value {
- color: #212121;
- font-weight: 400;
- text-align: right;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
-}
-
-.preview-cv-value.coupon-mono {
- font-family: "Roboto Mono", monospace;
- font-size: 0.8125rem;
- color: #757575;
-}
-
-/* ============================================================================
- LOADING & EMPTY STATES - BOTTOM PANEL
- ============================================================================ */
-
-.loading-container {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 12px;
- padding: 48px;
- color: #757575;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
-}
-
-.empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 12px;
- padding: 48px;
- color: #757575;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
-}
-
-.empty-state i {
- font-size: 3rem;
- color: #bdbdbd;
-}
-
-/* ============================================================================
- PROMOS TABLE
- ============================================================================ */
-
-/* p-table outer wrapper — see styles.scss */
-/* PrimeNG p-table internals (th, td, hover) are in styles.scss under body .promos-table */
-
-/* Inactive row styling */
-.inactive-row td {
- color: #9e9e9e;
- background: #fafafa;
- opacity: 0.7;
-}
-
-/* Activate-badge button: replaces the static inactive badge.
- Matches agm-badge pill styling exactly; hover reveals amber tint to signal it is clickable. */
-.activate-badge-btn {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 3px 8px;
- border-radius: 12px;
- border: 1px solid #4CAF50;
- background: #A5D6A7;
- color: #ffffff;
- font-size: 11px;
- font-weight: 600;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
- cursor: pointer;
- transition: all 0.2s ease-in-out;
- white-space: nowrap;
- line-height: 1.4;
-}
-
-.activate-badge-btn:hover:not(:disabled) {
- background: #FFC107;
- border-color: #FF8F00;
- color: #212121;
- box-shadow: 0 2px 6px rgba(255, 193, 7, 0.35);
- transform: translateY(-1px);
-}
-
-.activate-badge-btn:disabled {
- cursor: not-allowed;
- opacity: 0.7;
-}
-
-.activate-badge-btn .pi {
- font-size: 9px;
-}
-
-/* Coupon cell */
-.coupon-cell {
- vertical-align: top;
-}
-
-.coupon-cell-content {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.coupon-name-primary {
- font-size: 0.875rem;
- color: #212121;
- font-weight: 500;
- line-height: 1.3;
-}
-
-/* Name column two-line layout */
-.name-cell {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.name-primary {
- font-size: 0.875rem;
- color: #212121;
- font-weight: 500;
- line-height: 1.3;
-}
-
-/* i18n indicator icon (shows when promo has translation key) */
-.i18n-indicator {
- font-size: 0.75rem;
- color: #03A9F4;
- margin-left: 6px;
- opacity: 0.7;
- cursor: help;
-}
-
-.i18n-indicator:hover {
- opacity: 1;
-}
-
-/* Usage cell */
-.usage-cell {
- text-align: center;
- font-weight: 500;
-}
-
-/* Tools cell */
-.tools-cell {
- white-space: nowrap;
-}
-
-.tools-buttons {
- display: inline-flex;
- gap: 4px;
-}
-
-.button-ttip {
- display: inline-block;
-}
-
-/* ============================================================================
- EDIT ROW (EXPANDED)
- ============================================================================ */
-
-.edit-row td {
- background: #fff;
- padding: 0 !important;
-}
-
-.edit-panel {
- padding: 12px 16px;
- border-top: 2px solid #FFC107;
- border-radius: 0 0 6px 6px;
- display: flex;
- flex-direction: column;
-}
-
-/* Header: mirrors cv-header */
-.edit-cv-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
-}
-
-.edit-cv-package-info {
- display: flex;
- align-items: center;
- /* gap: 8px; */
-}
-
-.edit-cv-icon {
- font-size: 1rem;
- color: #FF8F00;
-}
-
-/* Editing pill: amber tone vs green Preview pill */
-.edit-pill {
- font-size: 0.6875rem;
- font-weight: 600;
- padding: 2px 8px;
- border-radius: 10px;
- background: #FFF3E0;
- color: #E65100;
- border: 1px solid #FFC107;
- letter-spacing: 0.5px;
- text-transform: uppercase;
- white-space: nowrap;
-}
-
-/* Divider: mirrors cv-divider */
-.edit-cv-divider {
- height: 1px;
- background: #E0E0E0;
- margin: 8px 0;
-}
-
-/* Context section: mirrors cv-section */
-.edit-cv-section {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding-left: 1em;
-}
-
-/* Context rows: mirrors cv-row */
-.edit-cv-row {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- font-size: 14px;
- line-height: 1.4;
-}
-
-/* Label: mirrors cv-label */
-.edit-cv-label {
- color: #757575;
- font-weight: 500;
- flex-shrink: 0;
- margin-right: 12px;
-}
-
-/* Value: mirrors cv-value */
-.edit-cv-value {
- color: #212121;
- font-weight: 400;
- text-align: right;
-}
-
-.edit-promo-name {
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- font-size: 1rem;
- font-weight: 500;
- color: #E65100;
- letter-spacing: 0.25px;
-}
-
-/* Edit header: two-line promo name block (mirrors preview heading) */
-.edit-header-name-block {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.edit-header-name-primary {
- font-size: 1rem;
- font-weight: 600;
- color: #212121;
- line-height: 1.3;
-}
-
-.edit-content {
- display: flex;
- align-items: flex-end;
- gap: 32px;
- flex-wrap: wrap;
- justify-content: flex-end;
-}
-
-.edit-field {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.edit-field label {
- font-family: "Roboto", "Helvetica Neue", sans-serif;
- font-size: 0.75rem;
- font-weight: 500;
- color: #757575;
-}
-
-/* p-calendar outer wrapper in edit row — see styles.scss */
-/* PrimeNG p-calendar internals are in styles.scss under body .edit-calendar */
-
-.edit-actions {
- display: flex;
- gap: 8px;
- /* Removed margin-left: auto - keep buttons close to calendar */
-}
-
-/* ============================================================================
- DELETE DIALOG
- ============================================================================ */
-
-/* p-dialog outer wrapper — see styles.scss */
-/* PrimeNG p-dialog internals are in styles.scss under body .delete-dialog */
-
-.dialog-content {
- padding: 8px 0;
- font-family: "Roboto", "Helvetica Neue", sans-serif;
-}
-
-.dialog-content p {
- margin: 0 0 12px 0;
- color: #212121;
-}
-
-.dialog-field {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-bottom: 16px;
-}
-
-.dialog-field label {
- font-weight: 500;
- color: #757575;
- white-space: nowrap;
-}
-
-/* p-calendar inside dialog — see styles.scss */
-/* agm-constraint-message icon colors inside dialog — in styles.scss under body .delete-dialog */
-
-/* ============================================================================
- RESPONSIVE ADJUSTMENTS
- ============================================================================ */
-
-/* Medium screens - form wraps, group keeps Valid Until + Add together */
-@media (max-width: 1150px) {
- .form-field {
- min-width: 180px;
- }
-
- .form-field-wide {
- min-width: 200px;
- }
-
- .form-field-button {
- padding-top: 22px;
- }
-}
-
-/* Small screens - single column layout */
-@media (max-width: 768px) {
- .subscription-mgt-container {
- padding: 12px 16px;
- }
-
- .form-row {
- flex-direction: column;
- }
-
- .form-field,
- .form-field-wide,
- .form-field-button,
- .form-field-group {
- flex: none;
- width: 100%;
- min-width: unset;
- }
-
- .form-field-group {
- flex-direction: column;
- gap: 12px;
- }
-
- .form-field-group .form-field {
- width: 100%;
- }
-
- .form-field-button {
- padding-top: 8px;
- }
-
- /* Preview area - mobile optimizations (ultra-compact) */
- .preview-card {
- padding: 12px;
- }
-
- .preview-cv-name {
- font-size: 0.875rem;
- word-break: break-word;
- }
-
- .preview-cv-row {
- font-size: 0.8125rem;
- }
-
- .preview-pill {
- font-size: 0.625rem;
- }
-
-/* PrimeNG table/calendar/dialog responsive overrides are in styles.scss under body .xxx @media (max-width: 768px) */
-
- .tools-buttons {
- display: inline-flex;
- gap: 8px;
- }
-
- /* Expanded edit row in responsive mode */
- .edit-row {
- display: table-row !important;
- }
-
- .edit-row td {
- display: block !important;
- width: 100% !important;
- text-align: left !important;
- }
-
- .edit-row td::before {
- display: none !important;
- }
-}
\ No newline at end of file
diff --git a/Development/client/src/app/settings/subscription/subscription-mgt.component.html b/Development/client/src/app/settings/subscription/subscription-mgt.component.html
deleted file mode 100644
index 0f4ce75..0000000
--- a/Development/client/src/app/settings/subscription/subscription-mgt.component.html
+++ /dev/null
@@ -1,402 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- No promos found. Create one above.
-
-
-
- 0" [value]="promos" [rowTrackBy]="trackByPromoId"
- styleClass="p-datatable-sm promos-table" [rowHover]="true" [responsive]="true">
-
-
- Type
- Name
- Coupon
- Valid Until
- Usage
- Status
- Tools
-
-
-
-
-
-
-
- Type
- {{ formatSubType(promo?.type) }}
-
-
- Name
-
- {{ promo.name || getPromoDisplayName(promo) }}
-
-
-
-
- Coupon
-
- {{ getCouponName(promo.couponId) }}
-
- —
-
-
- Valid Until
- {{ formatDate(promo.validUntil) }}
-
-
- Usage
- {{ promo.usageCount }}
-
-
- Status
-
-
-
-
-
- Inactive
-
-
-
- Tools
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Type
- {{ formatSubType(promo.type) }}
-
-
- Applies to
- {{ getPromoDisplayName(promo) }}
-
-
-
-
-
-
-
-
- Coupon
- {{ getCouponName(promo.couponId) }}
-
-
- Discount
- {{ getCouponDiscountSummary(promo.couponId) }}
-
-
-
-
-
-
- Eligibility
- {{ getEligibilityLabel(promo.eligibility) }}
-
-
-
-
-
-
-
-
- Promo Name:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
0">
-
-
- To disable this promo, set an expiry date:
-
-
-
-
-
-
-
-
-
- It will be permanently deleted.
-
-
-
-
-
-
- 0 ? 'Disable Promo' : 'Delete'"
- i18n-label="@@disableOrDelete" class="p-button-danger"
- [disabled]="deletePromo?.usageCount > 0 && !deleteValidUntil" (click)="onConfirmDelete()">
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Development/client/src/app/settings/subscription/subscription-mgt.component.ts b/Development/client/src/app/settings/subscription/subscription-mgt.component.ts
deleted file mode 100644
index c029a1e..0000000
--- a/Development/client/src/app/settings/subscription/subscription-mgt.component.ts
+++ /dev/null
@@ -1,1140 +0,0 @@
-import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
-import { FormBuilder, FormGroup, Validators } from '@angular/forms';
-import { Subject } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
-import { SelectItem } from 'primeng/api';
-import { Labels, locales } from '@app/shared/global';
-import { subPlans, SERVICE_TYPE, PromoErrors } from '@app/profile/common';
-import { PromoService, Promo, CreatePromoRequest, StripeCoupon } from './promo.service';
-import { BadgeConfig, BadgeType } from '@app/shared/badge/badge-config.model';
-import { BadgeFactoryService } from '@app/shared/services/badge-factory.service';
-import { AppMessageService } from '@app/shared/app-message.service';
-import { AppConfigService } from '@app/domain/services/app-config.service';
-
-// ============================================================================
-// FORM FIELD NAME CONSTANTS
-// ============================================================================
-
-/** Typed constants for all create-promo form control names — eliminates typo bugs */
-export const PromoFormFields = {
- subType: 'subType',
- priceKey: 'priceKey',
- validUntil: 'validUntil',
- couponId: 'couponId',
- promoName: 'promoName',
- eligibility: 'eligibility',
-} as const;
-
-/** Short alias for use within this file */
-const F = PromoFormFields;
-
-// ============================================================================
-// INTERFACES
-// ============================================================================
-
-/**
- * Represents a promo row in the table with UI state
- */
-interface PromoRow extends Promo {
- isExpanded: boolean;
- editValidUntil: Date | null;
- editName: string;
- isActivateExpanded: boolean;
- activateValidUntil: Date | null;
- couponData?: StripeCoupon; // Full coupon data for edit eligibility check
-}
-
-// ============================================================================
-// COMPONENT DEFINITION
-// ============================================================================
-
-@Component({
- selector: 'agm-subscription-mgt',
- templateUrl: './subscription-mgt.component.html',
- styleUrls: ['./subscription-mgt.component.css']
-})
-export class SubscriptionMgtComponent implements OnInit, OnDestroy {
-
- // ============================================================================
- // CORE PROPERTIES
- // ============================================================================
-
- /** Reference to Labels for template access */
- readonly Labels = Labels;
-
- /** Form control name constants — exposed for template bindings */
- readonly F = PromoFormFields;
-
- /** Loading state for promos table */
- loading = true;
-
- /** Loading state for form submission */
- submitting = false;
-
- /** Destroy subject for cleanup */
- private destroy$ = new Subject();
-
- // ============================================================================
- // FORM PROPERTIES - TOP PANEL (CREATE PROMO)
- // ============================================================================
-
- /** Reactive form for promo creation */
- createForm: FormGroup;
-
- /** Sub Type dropdown options */
- subTypeOptions: SelectItem[] = [
- { label: $localize`:@@typeAll:All`, value: 'all' },
- { label: $localize`:@@typePackage:Package`, value: 'package' },
- { label: $localize`:@@typeAddon:Addon`, value: 'addon' }
- ];
-
- /** Eligibility dropdown options */
- eligibilityOptions: SelectItem[] = [
- { label: $localize`:@@eligibilityAll:All Customers`, value: 'all' },
- { label: $localize`:@@eligibilityNew:New Customers Only`, value: 'new_only' },
- { label: $localize`:@@eligibilityExisting:Renewing Customers Only`, value: 'renew_only' }
- ];
-
- /** Package/Addon dropdown options (dynamic based on subType) */
- priceKeyOptions: SelectItem[] = [];
-
- /** Coupon dropdown options */
- couponOptions: SelectItem[] = [];
-
- /** Full coupon objects from Stripe (for discount value lookup) */
- availableCoupons: StripeCoupon[] = [];
-
- /** Currently selected coupon (for duration checking) */
- selectedCoupon: StripeCoupon | null = null;
-
- /** Reference to the promoName input for auto-focus after coupon selection */
- @ViewChild('promoNameInput') promoNameInputRef: ElementRef;
-
- /** Minimum date for validUntil. Set in ngOnInit from promoMinExpiryDays app setting. */
- minDate: Date;
-
- /** Date format from global locales (for p-calendar) */
- readonly dateFormat: string = locales.en.dateFormat;
-
- /** Year range for calendar year navigator (current year to +10 years) */
- readonly yearRange: string = `${new Date().getFullYear()}:${new Date().getFullYear() + 10}`;
-
- // ============================================================================
- // TABLE PROPERTIES - BOTTOM PANEL (EXISTING PROMOS)
- // ============================================================================
-
- /** Promo data for table */
- promos: PromoRow[] = [];
-
- /** Currently expanded row ID for editing */
- expandedRowId: string | null = null;
-
- // ============================================================================
- // DELETE DIALOG PROPERTIES
- // ============================================================================
-
- /** Whether delete dialog is visible */
- showDeleteDialog = false;
-
- /** Promo being deleted */
- deletePromo: PromoRow | null = null;
-
- /** Valid Until date for delete (when promo has usage) */
- deleteValidUntil: Date | null = null;
-
- /** Minimum date for delete validUntil (3 days from now) */
- deleteMinDate: Date;
-
- // ============================================================================
- // ACTIVATE PROMO PROPERTIES
- // ============================================================================
-
- /** Tracks which promo IDs are currently being activated (prevents double-clicks) */
- activatingPromoIds = new Set();
-
- // ============================================================================
- // CONSTRUCTOR
- // ============================================================================
-
- constructor(
- private fb: FormBuilder,
- private promoService: PromoService,
- private badgeFactory: BadgeFactoryService,
- private msgSvc: AppMessageService,
- private appConfSvc: AppConfigService
- ) { }
-
- // ============================================================================
- // LIFECYCLE METHODS
- // ============================================================================
-
- ngOnInit(): void {
- // Apply PROMO_MIN_EXPIRY_DAYS grace period to both calendar minDates.
- // SettingsGuard ensures AppConfigService.settings is loaded before this route activates.
- const graceDays = this.appConfSvc.settings?.promoMinExpiryDays ?? 0;
-
- // minDate — create promo calendar
- const d = new Date();
- if (graceDays > 0) {
- d.setDate(d.getDate() + graceDays);
- }
- this.minDate = d;
-
- // deleteMinDate — disable dialog calendar (must stay in sync with backend PROMO_MIN_EXPIRY_DAYS)
- const deleteDays = graceDays > 0 ? graceDays : 3;
- this.deleteMinDate = new Date();
- this.deleteMinDate.setDate(this.deleteMinDate.getDate() + deleteDays);
-
- this.initForm();
- this.loadPromos();
- this.loadCoupons();
- }
-
- ngOnDestroy(): void {
- this.destroy$.next();
- this.destroy$.complete();
- }
-
- // ============================================================================
- // FORM INITIALIZATION
- // ============================================================================
-
- /**
- * Initializes the create promo form with validators
- */
- private initForm(): void {
- this.createForm = this.fb.group({
- [F.subType]: ['package', Validators.required],
- [F.priceKey]: [null, Validators.required],
- [F.validUntil]: [null, Validators.required], // Required by default, cleared for repeating in onCouponSelected
- [F.couponId]: [null, Validators.required],
- [F.promoName]: ['', Validators.required],
- [F.eligibility]: ['all', Validators.required]
- });
-
- // Update priceKey options when subType changes
- this.createForm.get(F.subType)?.valueChanges
- .pipe(takeUntil(this.destroy$))
- .subscribe(subType => {
- this.updatePriceKeyOptions(subType);
-
- // Reset priceKey: 'all' for universal type, null otherwise
- const defaultPriceKey = subType === 'all' ? 'all' : null;
- const priceKeyControl = this.createForm.get(F.priceKey);
- if (priceKeyControl) {
- priceKeyControl.setValue(defaultPriceKey);
- priceKeyControl.markAsPristine();
- // Mark touched only when null so "Required" shows immediately after type selection
- if (defaultPriceKey === null) {
- priceKeyControl.markAsTouched();
- } else {
- priceKeyControl.markAsUntouched();
- }
- priceKeyControl.updateValueAndValidity();
- }
-
- // Reset coupon + promoName so the new type starts fresh.
- // Setting couponId to null triggers the couponId valueChanges subscription,
- // which calls onCouponSelected(null) and correctly resets selectedCoupon
- // and the validUntil required/optional validator.
- const couponControl = this.createForm.get(F.couponId);
- couponControl?.setValue(null);
- couponControl?.markAsUntouched();
- couponControl?.markAsPristine();
-
- const promoNameControl = this.createForm.get(F.promoName);
- promoNameControl?.setValue('');
- promoNameControl?.markAsUntouched();
- promoNameControl?.markAsPristine();
-
- });
-
- // Watch for coupon selection changes to show/hide Valid Until field
- this.createForm.get(F.couponId)?.valueChanges
- .pipe(takeUntil(this.destroy$))
- .subscribe(couponId => {
- this.onCouponSelected(couponId);
- });
-
- // Initialize with package options
- this.updatePriceKeyOptions('package');
- // priceKey starts null with type=Package — mark touched so "Required" shows immediately
- this.createForm.get(F.priceKey)?.markAsTouched();
- }
-
- /**
- * Updates priceKey dropdown options based on selected subType
- * Note: Enterprise (ENT) packages are excluded as they are not available yet
- */
- private updatePriceKeyOptions(subType: string): void {
- const plans = Object.entries(subPlans);
-
- // Handle "All" type selection
- if (subType === 'all') {
- // When Type = "All", show single "All Packages/Addons" option
- this.priceKeyOptions = [
- { label: $localize`:@@allPackagesAddons:All Packages/Addons`, value: 'all' }
- ];
- return;
- }
-
- if (subType === 'package') {
- this.priceKeyOptions = [
- { label: $localize`:@@allPackages:All Packages`, value: 'all' },
- ...plans
- .filter(([_, plan]) => plan.type === SERVICE_TYPE.ESS)
- .map(([key, plan]) => ({
- label: plan.name,
- value: key.toLowerCase()
- }))
- ];
- } else {
- this.priceKeyOptions = [
- { label: $localize`:@@allAddons:All Addons`, value: 'all' },
- ...plans
- .filter(([_, plan]) => plan.type === SERVICE_TYPE.ADDON)
- .map(([key, plan]) => ({
- label: plan.name,
- value: key.toLowerCase()
- }))
- ];
- }
- }
-
- // ============================================================================
- // DATA LOADING
- // ============================================================================
-
- /**
- * Loads existing promos from the service
- */
- private loadPromos(): void {
- this.loading = true;
- this.promoService.getPromos()
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (promos) => {
- this.promos = promos?.map(p => {
- const transformedPromo = this.transformPromoFromBackend(p);
- // Enrich with coupon data for edit eligibility check
- const couponData = this.availableCoupons.find(c => c.id === p.couponId);
- return {
- ...transformedPromo,
- isExpanded: false,
- editValidUntil: null,
- editName: '',
- isActivateExpanded: false,
- activateValidUntil: null,
- couponData
- };
- }) ?? [];
- this.loading = false;
- },
- error: (err) => {
- console.error('Error loading promos:', err);
- this.msgSvc.addFailedMsg($localize`:@@errorLoadingPromos:Failed to load promos. Please refresh the page.`);
- this.promos = [];
- this.loading = false;
- }
- });
- }
-
- /**
- * Loads available coupons for dropdown
- * Stores full coupon objects for discount value lookup during promo creation
- */
- private loadCoupons(): void {
- this.promoService.getAvailableCoupons()
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (coupons) => {
- this.availableCoupons = coupons ?? [];
-
- // Create dropdown options
- this.couponOptions = coupons?.map(c => ({
- label: `${c.name}`,
- value: c.id
- })) ?? [];
-
- // Enrich existing promos with coupon data (handles race condition)
- this.enrichPromosWithCouponData();
- },
- error: (err) => {
- console.error('Error loading coupons:', err);
- this.msgSvc.addWarnMsg($localize`:@@errorLoadingCoupons:Failed to load coupons. Some features may be unavailable.`);
- this.availableCoupons = [];
- this.couponOptions = [];
- }
- });
- }
-
- /**
- * Enriches promos with coupon data for edit eligibility check
- * Called after coupons are loaded to handle race condition
- */
- private enrichPromosWithCouponData(): void {
- this.promos = this.promos.map(promo => ({
- ...promo,
- couponData: this.availableCoupons.find(c => c.id === promo.couponId)
- }));
- }
-
- // ============================================================================
- // PREVIEW AND COUPON SELECTION HANDLING
- // ============================================================================
-
- /**
- * Handles coupon selection changes - updates Valid Until validation based on duration
- */
- onCouponSelected(couponId: string): void {
- if (!couponId) {
- this.selectedCoupon = null;
- // Reset to required validation when no coupon selected
- this.createForm.get(F.validUntil)?.setValidators([Validators.required]);
- this.createForm.get(F.validUntil)?.setValue(null);
- this.createForm.get(F.validUntil)?.updateValueAndValidity();
- return;
- }
-
- // Find selected coupon from available coupons
- this.selectedCoupon = this.availableCoupons.find(c => c.id === couponId) || null;
-
- // Update validators based on coupon duration
- const validUntilControl = this.createForm.get(F.validUntil);
- if (this.selectedCoupon?.duration === 'repeating') {
- // Repeating coupons: validUntil is optional
- validUntilControl?.clearValidators();
- validUntilControl?.setValue(null); // Clear the date value
- validUntilControl?.setErrors(null); // Clear any existing validation errors
- validUntilControl?.markAsUntouched(); // Reset touched state
- } else {
- // Forever/Once coupons: validUntil is required — mark touched so "Required" shows immediately
- validUntilControl?.setValidators([Validators.required]);
- validUntilControl?.markAsTouched();
- }
- validUntilControl?.updateValueAndValidity();
-
- // Auto-focus promoName input and prefill suggested name if currently empty
- setTimeout(() => {
- if (this.promoNameInputRef?.nativeElement) {
- this.promoNameInputRef.nativeElement.focus();
- }
- const promoNameCtrl = this.createForm?.get(F.promoName);
- if (promoNameCtrl && !promoNameCtrl.value) {
- promoNameCtrl.setValue(this.suggestPromoName(this.selectedCoupon));
- }
- }, 0);
- }
-
- /**
- * Generates a suggested promo name from a coupon's discount type and duration.
- * Only used as a prefill default — user can overwrite freely.
- * Examples: "$150off-12mo", "50pct-Forever", "Free-1mo"
- */
- private suggestPromoName(coupon: StripeCoupon | null): string {
- if (!coupon) { return ''; }
-
- let discountPart: string;
- if (coupon.percent_off === 100) {
- discountPart = 'Free';
- } else if (coupon.percent_off) {
- discountPart = `${coupon.percent_off}pct`;
- } else if (coupon.amount_off) {
- const dollars = Math.round(coupon.amount_off / 100);
- discountPart = `$${dollars}off`;
- } else {
- discountPart = 'Free';
- }
-
- let durationPart: string;
- if (coupon.duration === 'forever') {
- durationPart = 'Forever';
- } else if (coupon.duration === 'once') {
- durationPart = '1mo';
- } else if (coupon.duration === 'repeating' && coupon.duration_in_months) {
- durationPart = `${coupon.duration_in_months}mo`;
- } else {
- durationPart = '';
- }
-
- return durationPart ? `${discountPart}-${durationPart}` : discountPart;
- }
-
- /**
- * Checks if Valid Until field is required based on selected coupon duration
- * @returns true if validUntil is required (forever/once), false if optional (repeating)
- */
- isValidUntilRequired(): boolean {
- return this.selectedCoupon?.duration !== 'repeating';
- }
-
- /**
- * Checks if preview should be visible.
- * Requires the identity fields (couponId) to be filled.
- */
- get previewVisible(): boolean {
- const form = this.createForm;
- return !!(form.get(F.couponId)?.value);
- }
-
- /**
- * Auto-generates preview name from form values (plan name only)
- */
- get previewApplyTo(): string {
- const form = this.createForm;
- const subType = form.get(F.subType)?.value;
- const priceKey = form.get(F.priceKey)?.value;
-
- if (!priceKey && priceKey !== 'all') return '?';
-
- // Handle universal promo (type = 'all', priceKey = 'all')
- if (subType === 'all' && priceKey === 'all') {
- return $localize`:@@allPackagesAddons:All Packages/Addons`;
- }
-
- // Handle type-specific "All" (e.g., type = 'package', priceKey = 'all')
- if (priceKey === 'all') {
- return subType === 'package'
- ? $localize`:@@allPackages:All Packages`
- : $localize`:@@allAddons:All Addons`;
- }
-
- // Get specific package name from priceKey
- return this.getPlanNameByKey(priceKey);
- }
-
- /**
- * Gets coupon name for preview details
- */
- get previewCouponLabel(): string {
- return this.selectedCoupon?.name || '';
- }
-
- /**
- * Gets dynamic label for expiry section based on coupon type
- * Returns "Redeem by" when coupon selected, "Expires" otherwise
- */
- get previewExpiryLabel(): string {
- return this.selectedCoupon
- ? $localize`:@@redeemByLabel:Redeem by`
- : $localize`:@@expiresLabel:Expires`;
- }
-
- /**
- * Gets redeem-by / expiry date value. Returns '' if no date set (row hidden via *ngIf).
- */
- get previewExpiryValue(): string {
- const validUntil = this.createForm.get(F.validUntil)?.value;
- return validUntil ? this.formatPreviewDate(validUntil) : '';
- }
-
- /**
- * Gets coupon discount summary: amount/percent + duration for repeating coupons.
- * Returns '' when no coupon selected (row hidden via *ngIf).
- */
- get previewDiscountValue(): string {
- if (!this.selectedCoupon) { return ''; }
-
- // Build discount amount string
- let discountStr = '';
- if (this.selectedCoupon.percent_off === 100) {
- discountStr = $localize`:@@freeLabel:Free`;
- } else if (this.selectedCoupon.percent_off) {
- discountStr = `${this.selectedCoupon.percent_off}% off`;
- } else if (this.selectedCoupon.amount_off) {
- const dollars = (this.selectedCoupon.amount_off / 100).toFixed(0);
- discountStr = `$${dollars} off`;
- }
-
- // Append duration for repeating coupons
- if (this.selectedCoupon.duration === 'repeating' && this.selectedCoupon.duration_in_months) {
- const months = this.selectedCoupon.duration_in_months;
- const monthLabel = months === 1
- ? $localize`:@@monthSingular:month`
- : $localize`:@@monthsPlural:months`;
- discountStr += ` - ${months} ${monthLabel}`;
- }
-
- return discountStr;
- }
-
- /**
- * Gets plan display name from priceKey
- */
- getPlanNameByKey(priceKey: string): string {
- if (!priceKey) return '';
- const lowerKey = priceKey.toLowerCase();
- const plan = subPlans[lowerKey];
- return plan?.name || priceKey;
- }
-
- /**
- * Gets coupon display name from couponId
- */
- getCouponName(couponId: string): string {
- const coupon = this.availableCoupons.find(c => c.id === couponId);
- return coupon?.name || couponId;
- }
-
- /**
- * Returns discount summary string for a coupon: amount/percent + duration.
- * Used in table coupon cell and edit panel context strip.
- */
- getCouponDiscountSummary(couponId: string): string {
- const coupon = this.availableCoupons.find(c => c.id === couponId);
- if (!coupon) { return ''; }
-
- let discountStr = '';
- if (coupon.percent_off === 100) {
- discountStr = $localize`:@@freeLabel:Free`;
- } else if (coupon.percent_off) {
- discountStr = `${coupon.percent_off}% off`;
- } else if (coupon.amount_off) {
- const dollars = (coupon.amount_off / 100).toFixed(0);
- discountStr = `$${dollars} off`;
- }
-
- if (coupon.duration === 'repeating' && coupon.duration_in_months) {
- const months = coupon.duration_in_months;
- const monthLabel = months === 1
- ? $localize`:@@monthSingular:month`
- : $localize`:@@monthsPlural:months`;
- discountStr += ` - ${months} ${monthLabel}`;
- } else if (coupon.duration === 'forever' && discountStr) {
- discountStr += ` - ` + $localize`:@@foreverLabel:forever`;
- }
-
- return discountStr;
- }
-
- /**
- * Formats date for preview display
- */
- private formatPreviewDate(date: Date): string {
- return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
- }
-
- // ============================================================================
- // FORM SUBMISSION - ADD PROMO
- // ============================================================================
-
- /**
- * Handles Add Promo button click
- */
- onAddPromo(): void {
- if (this.createForm.invalid) {
- // Mark all fields as touched to show validation errors
- Object.keys(this.createForm.controls).forEach(key => {
- this.createForm.get(key)?.markAsTouched();
- });
- return;
- }
-
- this.submitting = true;
-
- // Use getRawValue() to get form values
- const formValue = this.createForm.getRawValue();
-
- // Transform frontend 'all' values to backend empty strings ''
- const transformed = this.transformPromoForBackend(formValue);
-
- // Lookup selected coupon from already-loaded data (no API call needed!)
- const selectedCoupon = this.availableCoupons.find(c => c.id === formValue.couponId);
-
- if (!selectedCoupon) {
- this.submitting = false;
- this.msgSvc.addFailedMsg($localize`:@@couponNotFound:Selected coupon not found. Please refresh the page.`);
- return;
- }
-
- // Extract discount values from Stripe coupon data
- let discountType: 'free' | 'percent' | 'fixed';
- let discountValue: number;
-
- if (selectedCoupon.percent_off !== undefined && selectedCoupon.percent_off !== null) {
- // Percentage-based discount
- discountType = selectedCoupon.percent_off === 100 ? 'free' : 'percent';
- discountValue = selectedCoupon.percent_off;
- } else if (selectedCoupon.amount_off !== undefined && selectedCoupon.amount_off !== null) {
- // Fixed amount discount
- discountType = 'fixed';
- discountValue = selectedCoupon.amount_off; // in cents
- } else {
- // Invalid coupon - no discount defined
- this.submitting = false;
- this.msgSvc.addFailedMsg($localize`:@@invalidCoupon:Invalid coupon: no discount amount defined.`);
- return;
- }
-
- // Build request with actual Stripe coupon values
- const request: CreatePromoRequest = {
- type: transformed.type,
- priceKey: transformed.priceKey,
- discountType: discountType, // ✅ From Stripe coupon
- discountValue: discountValue, // ✅ From Stripe coupon
- validUntil: formValue.validUntil
- ? formValue.validUntil.toISOString()
- : new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString(), // 100 years far-future for optional/repeating
- couponId: formValue.couponId,
- name: (formValue.promoName as string)?.trim() || this.previewApplyTo,
- enabled: true, // New promos are enabled by default
- eligibility: formValue.eligibility || 'all'
- };
-
- this.promoService.addPromo(request)
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (promos) => {
- // Backend returns full list - refresh table with extended promo data
- this.promos = promos?.map(p => {
- const couponData = this.availableCoupons.find(c => c.id === p.couponId);
- return {
- ...p,
- isExpanded: false,
- editValidUntil: null,
- editName: '',
- isActivateExpanded: false,
- activateValidUntil: null,
- couponData
- };
- }) ?? [];
-
- // Reset form to defaults
- this.createForm.reset({
- [F.subType]: 'package',
- [F.promoName]: '',
- [F.eligibility]: 'all'
- });
- this.updatePriceKeyOptions('package');
-
- this.submitting = false;
-
- this.msgSvc.addSuccessMsg($localize`:@@promoCreated:Promo created successfully`);
- },
- error: (err) => {
- console.error('Error creating promo:', err);
- this.submitting = false;
-
- // Show specific error message based on backend error type
- const errorMessage = this.getPromoErrorMessage(err);
- this.msgSvc.addFailedMsg(errorMessage);
- }
- });
- }
-
- /**
- * Map backend promo error type to admin-friendly message
- * @param error - Backend error response (HttpErrorResponse)
- * @returns User-friendly error message string
- */
- private getPromoErrorMessage(error: any): string {
- // Backend response is double-nested: HttpErrorResponse.error.error['.tag']
- const errorType = error?.error?.error?.['.tag'];
- const errorMessage = error?.error?.error?.message;
-
- switch (errorType) {
- case 'promo_not_found':
- return PromoErrors.PROMO_NOT_FOUND;
-
- case 'promo_duplicate_type_pricekey':
- return PromoErrors.PROMO_DUPLICATE_TYPE_PRICEKEY;
-
- case 'promo_duplicate_coupon':
- return PromoErrors.PROMO_DUPLICATE_COUPON;
-
- case 'promo_overlapping_dates':
- return PromoErrors.PROMO_OVERLAPPING_DATES;
-
- case 'promo_coupon_not_found':
- return PromoErrors.PROMO_COUPON_NOT_FOUND;
-
- case 'promo_invalid_coupon':
- return PromoErrors.PROMO_INVALID_COUPON;
-
- default:
- // Fallback for unknown error types
- console.error('Unknown promo error type:', errorType, 'Message:', errorMessage, 'Full error:', error);
- return PromoErrors.PROMO_GENERIC_ERROR;
- }
- }
-
- // ============================================================================
- // TABLE METHODS - EDIT
- // ============================================================================
-
- /**
- * Handles Edit button click - expands row for editing
- */
- onEditClick(promo: PromoRow, event: Event): void {
- event.stopPropagation();
-
- // Collapse any currently expanded row
- if (this.expandedRowId && this.expandedRowId !== promo._id) {
- const prevRow = this.promos.find(p => p._id === this.expandedRowId);
- if (prevRow) {
- prevRow.isExpanded = false;
- prevRow.editValidUntil = null;
- }
- }
-
- // Toggle expansion
- promo.isExpanded = !promo.isExpanded;
-
- if (promo.isExpanded) {
- this.expandedRowId = promo._id;
- promo.editValidUntil = new Date(promo.validUntil);
- promo.editName = promo.name || '';
- } else {
- this.expandedRowId = null;
- promo.editValidUntil = null;
- promo.editName = '';
- }
- }
-
- /**
- * Handles Save button click in edit mode
- */
- onSaveEdit(promo: PromoRow): void {
- const trimmedName = promo.editName?.trim() || '';
- if (!promo.editValidUntil && !trimmedName) return;
-
- this.promoService.updatePromo(promo._id, {
- ...(promo.editValidUntil ? { validUntil: promo.editValidUntil.toISOString() } : {}),
- ...(trimmedName ? { name: trimmedName } : {})
- })
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (updated) => {
- // Update local data
- if (updated.validUntil) { promo.validUntil = updated.validUntil; }
- if (trimmedName) { promo.name = trimmedName; }
- promo.isExpanded = false;
- promo.editValidUntil = null;
- promo.editName = '';
- this.expandedRowId = null;
-
- this.msgSvc.addSuccessMsg($localize`:@@promoUpdated:Promo updated successfully`);
- },
- error: (err) => {
- console.error('Error updating promo:', err);
- this.msgSvc.addFailedMsg($localize`:@@errorUpdatingPromo:Failed to update promo. Please try again.`);
- }
- });
- }
-
- /**
- * Handles Cancel button click in edit mode
- */
- onCancelEdit(promo: PromoRow): void {
- promo.isExpanded = false;
- promo.editValidUntil = null;
- promo.editName = '';
- this.expandedRowId = null;
- }
-
- /**
- * Opens the inline activate panel for an inactive promo.
- * Pre-fills activateValidUntil with today + 30 days.
- * Closes any other open edit or activate panel first.
- */
- onActivateClick(promo: PromoRow): void {
- if (this.activatingPromoIds.has(promo._id)) return;
-
- // Close any other open panel
- this.promos.forEach(p => {
- if (p._id !== promo._id) {
- p.isExpanded = false;
- p.isActivateExpanded = false;
- }
- });
-
- const isOpen = promo.isActivateExpanded;
- promo.isActivateExpanded = !isOpen;
- if (promo.isActivateExpanded) {
- // Pre-fill: today + 30 days
- const suggested = new Date();
- suggested.setDate(suggested.getDate() + 30);
- promo.activateValidUntil = suggested;
- this.expandedRowId = promo._id;
- } else {
- promo.activateValidUntil = null;
- this.expandedRowId = null;
- }
- }
-
- /**
- * Cancels the activate inline panel without making any changes.
- */
- onCancelActivate(promo: PromoRow): void {
- promo.isActivateExpanded = false;
- promo.activateValidUntil = null;
- this.expandedRowId = null;
- }
-
- /**
- * Sends PUT /:id with enabled=true + new validUntil so both isActive() checks pass.
- * On success, merges server response so the badge flips to ACTIVE immediately.
- */
- onConfirmActivate(promo: PromoRow): void {
- if (!promo || !promo.activateValidUntil || this.activatingPromoIds.has(promo._id)) return;
-
- this.activatingPromoIds.add(promo._id);
- this.promoService.activatePromo(promo._id, promo.activateValidUntil)
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (updated: Promo) => {
- this.activatingPromoIds.delete(promo._id);
- Object.assign(promo, updated);
- promo.isActivateExpanded = false;
- promo.activateValidUntil = null;
- this.expandedRowId = null;
- this.msgSvc.addSuccessMsg(Labels.PROMO_ACTIVATED_SUCCESS);
- },
- error: () => {
- this.activatingPromoIds.delete(promo._id);
- this.msgSvc.addFailedMsg(Labels.PROMO_ACTIVATE_FAILED);
- }
- });
- }
-
- /**
- * Determines if a promo can be edited
- * Forever/once coupons: Can edit validUntil
- * Repeating coupons: Can edit ONLY if validUntil is a real date (not far-future placeholder)
- */
- canEditPromo(promo: PromoRow): boolean {
- // Check if coupon data is available
- if (!promo.couponData) {
- // Fallback: hide edit button if coupon data not loaded yet (safer default)
- return false;
- }
-
- // Forever/Once coupons: Always allow editing validUntil
- if (promo.couponData.duration !== 'repeating') {
- return true;
- }
-
- // Repeating coupons: Only allow editing if validUntil is a real date (not far-future placeholder)
- const validUntilDate = new Date(promo.validUntil);
- const fiftyYearsFromNow = new Date();
- fiftyYearsFromNow.setFullYear(fiftyYearsFromNow.getFullYear() + 50);
-
- // If validUntil is more than 50 years in future, it's a placeholder - hide edit button
- return validUntilDate < fiftyYearsFromNow;
- }
-
- /**
- * Returns the display label for the given eligibility value.
- */
- getEligibilityLabel(eligibility: string | undefined): string {
- const opt = this.eligibilityOptions.find(o => o.value === (eligibility || 'all'));
- return opt ? opt.label : $localize`:@@eligibilityAll:All Customers`;
- }
-
- // ============================================================================
- // TABLE METHODS - DELETE
- // ============================================================================
-
- /**
- * Handles Delete button click - shows confirmation dialog
- */
- onDeleteClick(promo: PromoRow, event: Event): void {
- event.stopPropagation();
- this.deletePromo = promo;
- this.deleteValidUntil = null;
- this.showDeleteDialog = true;
- }
-
- /**
- * Handles delete dialog confirm button
- */
- onConfirmDelete(): void {
- if (!this.deletePromo) return;
-
- const validUntil = this.deletePromo.usageCount > 0 && this.deleteValidUntil
- ? this.deleteValidUntil.toISOString()
- : undefined;
-
- this.promoService.deletePromo(this.deletePromo._id, validUntil)
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (response) => {
- if (response.action === 'deleted') {
- this.promos = this.promos?.filter(p => p._id !== this.deletePromo?._id) ?? [];
- } else {
- const index = this.promos?.findIndex(p => p._id === this.deletePromo?._id) ?? -1;
- if (index !== -1) {
- this.promos[index] = {
- ...this.promos[index],
- ...response.promo,
- isExpanded: false,
- editValidUntil: null
- };
- }
- }
-
- this.showDeleteDialog = false;
- this.deletePromo = null;
- this.deleteValidUntil = null;
-
- const msg = response.action === 'deleted'
- ? $localize`:@@promoDeleted:Promo deleted successfully`
- : $localize`:@@promoDisabled:Promo disabled successfully`;
- this.msgSvc.addSuccessMsg(msg);
- },
- error: (err) => {
- console.error('Error deleting promo:', err);
- this.msgSvc.addFailedMsg($localize`:@@errorDeletingPromo:Failed to delete promo. Please try again.`);
- }
- });
- }
-
- /**
- * Handles delete dialog cancel button
- */
- onCancelDelete(): void {
- this.showDeleteDialog = false;
- this.deletePromo = null;
- this.deleteValidUntil = null;
- }
-
- // ============================================================================
- // DISPLAY HELPERS
- // ============================================================================
-
- /**
- * Formats date for table display using global locale format (mm/dd/yy)
- */
- formatDate(isoDate: string | Date): string {
- if (!isoDate) return '-';
- const date = new Date(isoDate);
- if (isNaN(date.getTime())) return '-';
-
- // Use global locale format: mm/dd/yy (2-digit year)
- const month = String(date.getMonth() + 1).padStart(2, '0');
- const day = String(date.getDate()).padStart(2, '0');
- const year = String(date.getFullYear()).slice(-2);
- return `${month}/${day}/${year}`;
- }
-
- /**
- * Determines promo status (active or inactive)
- */
- isActive(promo: Promo): boolean {
- if (!promo.enabled) return false;
- const validUntil = new Date(promo.validUntil);
- return validUntil > new Date();
- }
-
- /**
- * Gets status display text
- */
- getStatusText(promo: Promo): string {
- return this.isActive(promo)
- ? $localize`:@@statusActive:Active`
- : $localize`:@@statusInactive:Inactive`;
- }
-
- /**
- * Gets badge configuration for promo status
- */
- getStatusBadgeConfig(promo: Promo): BadgeConfig {
- if (this.isActive(promo)) {
- return this.badgeFactory.createActiveStatusBadge(
- $localize`:@@statusActive:Active`
- );
- }
- return {
- text: $localize`:@@statusInactive:Inactive`,
- type: BadgeType.STATUS_INACTIVE,
- tooltip: 'Promo is inactive',
- ariaLabel: 'Promo status: Inactive'
- };
- }
-
- /**
- * Transforms promo data from frontend format to backend format
- * Frontend uses 'all' for universal promos, backend expects empty string ''
- */
- private transformPromoForBackend(formValue: any): any {
- return {
- ...formValue,
- type: formValue.subType === 'all' ? '' : formValue.subType,
- priceKey: formValue.priceKey === 'all' ? '' : formValue.priceKey
- };
- }
-
- /**
- * Transforms promo data from backend format to frontend format
- * Backend uses empty string '' for universal promos, frontend needs 'all'
- */
- private transformPromoFromBackend(promo: any): any {
- return {
- ...promo,
- type: promo.type === '' ? 'all' : promo.type,
- priceKey: promo.priceKey === '' ? 'all' : promo.priceKey
- };
- }
-
- /**
- * Capitalizes first letter of sub type for display
- */
- formatSubType(type: string): string {
- // Handle 'all' type (frontend representation)
- if (type === 'all') return $localize`:@@typeAll:All`;
-
- // Handle empty string (backend representation for universal promos)
- if (type === '') return $localize`:@@typeAll:All`;
-
- // Handle undefined/null
- if (!type) return '';
-
- return type.charAt(0).toUpperCase() + type.slice(1);
- }
-
- /**
- * Gets display name for promo in table
- * Handles universal, type-specific "All", and specific packages
- */
- getPromoDisplayName(promo: Promo): string {
- // Universal promo (type = 'all', priceKey = 'all')
- if (promo.type === 'all' && promo.priceKey === 'all') {
- return $localize`:@@allPackagesAddons:All Packages/Addons`;
- }
-
- // Type-specific "All" (e.g., type = 'package', priceKey = 'all')
- if (promo.priceKey === 'all') {
- return promo.type === 'package'
- ? $localize`:@@allPackages:All Packages`
- : $localize`:@@allAddons:All Addons`;
- }
-
- // Specific package/addon (existing logic)
- return this.getPlanNameByKey(promo.priceKey);
- }
-
- /**
- * Gets warning message for promo in use (with dynamic count)
- * Note: Using method instead of template interpolation for i18n compliance
- */
- getPromoInUseMessage(): string {
- const count = this.deletePromo?.usageCount || 0;
- return $localize`:@@promoInUseWarning:This promo is used by ${count}:count: subscription(s).`;
- }
-
- /**
- * Gets i18n translation key tooltip for a promo
- * Shows nameKey if available, otherwise null (no tooltip)
- */
- getI18nKeyTooltip(promo: Promo): string | null {
- if (!promo.nameKey) return null;
- return `i18n: ${promo.nameKey}`;
- }
-
- /**
- * Track by function for ngFor optimization
- */
- trackByPromoId(index: number, promo: PromoRow): string {
- return promo._id;
- }
-}
diff --git a/Development/client/src/app/shared/account-editor/account-editor.component.css b/Development/client/src/app/shared/account-editor/account-editor.component.css
deleted file mode 100644
index 2ab08a3..0000000
--- a/Development/client/src/app/shared/account-editor/account-editor.component.css
+++ /dev/null
@@ -1,31 +0,0 @@
-/* ============================================================================
- ACCOUNT CONSTRAINT ICON POSITIONING (Detached Mode - Responsive)
- ============================================================================
- Positions constraint icon beside fieldset legend (similar to Account
- Information Incomplete pattern in vehicle-edit). Icon appears inline
- with legend, message content renders in parent component via
- *ngTemplateOutlet projection.
-
- Exception to FlexGrid: Absolute positioning required for space-efficient
- legend alignment without creating extra vertical space from ui-g-12 row.
- Switches to static positioning on mobile for better accessibility.
- ========================================================================= */
-
-.account-editor-inline-constraint {
- position: absolute;
- top: 0;
- right: 20px;
- z-index: 100;
- transform: translateY(0);
- /* Align with legend baseline */
-}
-
-/* Responsive: Switch to static positioning on mobile for better flow */
-@media (max-width: 768px) {
- .account-editor-inline-constraint {
- position: static;
- display: block;
- margin-bottom: 12px;
- text-align: right;
- }
-}
\ No newline at end of file
diff --git a/Development/client/src/app/shared/account-editor/account-editor.component.html b/Development/client/src/app/shared/account-editor/account-editor.component.html
index 2fbcac9..f4fd4db 100644
--- a/Development/client/src/app/shared/account-editor/account-editor.component.html
+++ b/Development/client/src/app/shared/account-editor/account-editor.component.html
@@ -1,20 +1,13 @@