LP1904036 Patron bills Full Details action
[evergreen-equinox.git] / Open-ILS / src / eg2 / src / app / staff / circ / patron / bills.component.ts
1 import {Component, Input, OnInit, AfterViewInit, ViewChild} from '@angular/core';
2 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
3 import {from, empty, range} from 'rxjs';
4 import {concatMap, tap, takeLast} from 'rxjs/operators';
5 import {NgbNav, NgbNavChangeEvent} from '@ng-bootstrap/ng-bootstrap';
6 import {IdlObject, IdlService} from '@eg/core/idl.service';
7 import {EventService} from '@eg/core/event.service';
8 import {OrgService} from '@eg/core/org.service';
9 import {NetService} from '@eg/core/net.service';
10 import {PcrudService, PcrudContext} from '@eg/core/pcrud.service';
11 import {AuthService} from '@eg/core/auth.service';
12 import {ServerStoreService} from '@eg/core/server-store.service';
13 import {PatronService} from '@eg/staff/share/patron/patron.service';
14 import {PatronContextService} from './patron.service';
15 import {GridDataSource, GridColumn, GridCellTextGenerator, GridRowFlairEntry} from '@eg/share/grid/grid';
16 import {GridComponent} from '@eg/share/grid/grid.component';
17 import {Pager} from '@eg/share/util/pager';
18 import {CircService, CircDisplayInfo} from '@eg/staff/share/circ/circ.service';
19 import {PrintService} from '@eg/share/print/print.service';
20 import {PromptDialogComponent} from '@eg/share/dialog/prompt.component';
21 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
22 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
23 import {CreditCardDialogComponent
24     } from '@eg/staff/share/billing/credit-card-dialog.component';
25 import {BillingService, CreditCardPaymentParams} from '@eg/staff/share/billing/billing.service';
26 import {AddBillingDialogComponent} from '@eg/staff/share/billing/billing-dialog.component';
27 import {AudioService} from '@eg/share/util/audio.service';
28 import {ToastService} from '@eg/share/toast/toast.service';
29 import {GridFlatDataService} from '@eg/share/grid/grid-flat-data.service';
30 import {WorkLogService} from '@eg/staff/share/worklog/worklog.service';
31
32 @Component({
33   templateUrl: 'bills.component.html',
34   selector: 'eg-patron-bills',
35   styleUrls: ['bills.component.css']
36 })
37 export class BillsComponent implements OnInit, AfterViewInit {
38
39     @Input() patronId: number;
40     summary: IdlObject;
41     sessionVoided = 0;
42     paymentType = 'cash_payment';
43     checkNumber: string;
44     paymentAmount: number;
45     annotatePayment = false;
46     paymentNote: string;
47     convertChangeToCredit = false;
48     receiptOnPayment = false;
49     applyingPayment = false;
50     numReceipts = 1;
51     ccPaymentParams: CreditCardPaymentParams;
52     disableAutoPrint = false;
53
54     maxPayAmount = 100000;
55     warnPayAmount = 1000;
56     voidAmount = 0;
57     refunding = false;
58
59     gridDataSource: GridDataSource = new GridDataSource();
60     cellTextGenerator: GridCellTextGenerator;
61     rowClassCallback: (row: any) => string;
62     rowFlairCallback: (row: any) => GridRowFlairEntry;
63
64     nowTime: number = new Date().getTime();
65
66     @ViewChild('billGrid') private billGrid: GridComponent;
67     @ViewChild('annotateDialog') private annotateDialog: PromptDialogComponent;
68     @ViewChild('maxPayDialog') private maxPayDialog: AlertDialogComponent;
69     @ViewChild('warnPayDialog') private warnPayDialog: ConfirmDialogComponent;
70     @ViewChild('voidBillsDialog') private voidBillsDialog: ConfirmDialogComponent;
71     @ViewChild('refundDialog') private refundDialog: ConfirmDialogComponent;
72     @ViewChild('adjustToZeroDialog') private adjustToZeroDialog: ConfirmDialogComponent;
73     @ViewChild('creditCardDialog') private creditCardDialog: CreditCardDialogComponent;
74     @ViewChild('billingDialog') private billingDialog: AddBillingDialogComponent;
75
76     constructor(
77         private router: Router,
78         private route: ActivatedRoute,
79         private audio: AudioService,
80         private toast: ToastService,
81         private org: OrgService,
82         private evt: EventService,
83         private net: NetService,
84         private pcrud: PcrudService,
85         private auth: AuthService,
86         private idl: IdlService,
87         private printer: PrintService,
88         private serverStore: ServerStoreService,
89         private circ: CircService,
90         private billing: BillingService,
91         private flatData: GridFlatDataService,
92         private worklog: WorkLogService,
93         public patronService: PatronService,
94         public context: PatronContextService
95     ) {}
96
97     ngOnInit() {
98
99         this.cellTextGenerator = {
100             title: row => row.title,
101             copy_barcode: row => row.copy_barcode,
102             call_number: row => row.call_number_label
103         };
104
105         this.rowClassCallback = (row: any): string => {
106             if (row['circulation.stop_fines'] === 'LOST') {
107                 return 'lost-row';
108             } else if (row['circulation.stop_fines'] === 'LONGOVERDUE') {
109                 return 'longoverdue-row';
110             } else if (this.circIsOverdue(row)) {
111                 return 'less-intense-alert';
112             }
113             return '';
114         };
115
116         this.rowFlairCallback = (row: any): GridRowFlairEntry => {
117             if (row['circulation.stop_fines'] === 'LOST') {
118                 return {icon: 'help'};
119             } else if (row['circulation.stop_fines'] === 'LONGOVERDUE') {
120                 return {icon: 'priority-high'};
121             } else if (this.circIsOverdue(row)) {
122                 return {icon: 'schedule'};
123             }
124         };
125
126         this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
127
128             const query: any = {
129                usr: this.patronId,
130                xact_finish: null,
131                balance_owed: {'<>' : 0}
132             };
133
134             return this.flatData.getRows(
135                 this.billGrid.context, query, pager, sort)
136             .pipe(tap(row => {
137                 row.paymentPending = 0;
138                 row.billingLocation =
139                     row['grocery.billing_location.shortname'] ||
140                     row['circulation.circ_lib.shortname'];
141             }));
142         };
143
144         this.pcrud.retrieve('mowbus', this.patronId).toPromise()
145         // Summary will be null for users with no billing history.
146         .then(summary => this.summary = summary || this.idl.create('mowbus'))
147         .then(_ => this.loadSettings());
148     }
149
150     circIsOverdue(row: any): boolean {
151         const due = row['circulation.due_date'];
152         if (due && !row['circulation.checkin_time']) {
153             const stopFines = row['circulation.stop_fines'] || '';
154             if (stopFines.match(/LOST|CLAIMSRETURNED|CLAIMSNEVERCHECKEDOUT/)) {
155                 return false;
156             }
157
158             return (Date.parse(due) < this.nowTime);
159         }
160     }
161
162     loadSettings(): Promise<any> {
163         return this.serverStore.getItemBatch([
164             'ui.circ.billing.amount_warn',
165             'ui.circ.billing.amount_limit',
166             'circ.staff_client.do_not_auto_attempt_print',
167             'circ.bills.receiptonpay',
168             'eg.circ.bills.annotatepayment'
169
170         ]).then(sets => {
171             this.maxPayAmount = sets['ui.circ.billing.amount_limit'] || 100000;
172             this.warnPayAmount = sets['ui.circ.billing.amount_warn'] || 1000;
173             this.receiptOnPayment = sets['circ.bills.receiptonpay'];
174             this.annotatePayment = sets['eg.circ.bills.annotatepayment'];
175
176             const noPrint = sets['circ.staff_client.do_not_auto_attempt_print'];
177             if (noPrint && noPrint.includes('Bill Pay')) {
178                 this.disableAutoPrint = true;
179             }
180         });
181     }
182
183     applySetting(name: string, value: any) {
184         this.serverStore.setItem(name, value);
185     }
186
187     ngAfterViewInit() {
188         // Recaclulate the amount owed per selected transaction as the
189         // grid rows selections change.
190         this.billGrid.context.rowSelector.selectionChange
191         .subscribe(_ => this.updatePendingColumn());
192
193         this.focusPayAmount();
194     }
195
196     focusPayAmount() {
197         setTimeout(() => {
198             const node = document.getElementById('pay-amount') as HTMLInputElement;
199             if (node) { node.focus(); node.select(); }
200         });
201     }
202
203     patron(): IdlObject {
204         return this.context.summary ? this.context.summary.patron : null;
205     }
206
207     selectedPaymentInfo(): {owed: number, billed: number, paid: number} {
208         const info = {owed : 0, billed : 0, paid : 0};
209
210         if (!this.billGrid) { return info; } // page loading
211
212         this.billGrid.context.rowSelector.selected().forEach(id => {
213             const row = this.billGrid.context.getRowByIndex(id);
214
215             if (!row) { return; } // Called mid-reload
216
217             info.owed   += Number(row.balance_owed) * 100;
218             info.billed += Number(row.total_owed) * 100;
219             info.paid   += Number(row.total_paid) * 100;
220         });
221
222         info.owed /= 100;
223         info.billed /= 100;
224         info.paid /= 100;
225
226         return info;
227     }
228
229
230     pendingPaymentInfo(): {payment: number, change: number} {
231
232         const amt = this.paymentAmount || 0;
233         const owedSelected = this.owedSelected();
234
235         if (amt >= owedSelected) {
236             return {
237                 payment : owedSelected,
238                 change : amt - owedSelected
239             };
240         }
241
242         return {payment : amt, change : 0};
243     }
244
245     disablePayment(): boolean {
246         if (!this.billGrid) { return true; } // still loading
247
248         return (
249             this.applyingPayment ||
250             !this.pendingPayment() ||
251             this.paymentAmount === 0 ||
252             (this.paymentAmount < 0 && !this.refunding) ||
253             this.billGrid.context.rowSelector.selected().length === 0
254         );
255     }
256
257     refundsAvailable(): number {
258         let amount = 0;
259         this.gridDataSource.data.forEach(row => {
260             const balance = row.balance_owed;
261             if (balance < 0) { amount += balance * 100; }
262
263         });
264
265         return -(amount / 100);
266     }
267
268     paidSelected(): number {
269         return this.selectedPaymentInfo().paid;
270     }
271
272     owedSelected(): number {
273         return this.selectedPaymentInfo().owed;
274     }
275
276     billedSelected(): number {
277         return this.selectedPaymentInfo().billed;
278     }
279
280     pendingPayment(): number {
281         return this.pendingPaymentInfo().payment;
282     }
283
284     pendingChange(): number {
285         return this.pendingPaymentInfo().change;
286     }
287
288     applyPayment() {
289         if (this.amountExceedsMax()) { return; }
290
291         this.applyingPayment = true;
292         this.paymentNote = '';
293         this.ccPaymentParams = {};
294         const payments = this.compilePayments();
295
296         this.verifyPayAmount()
297         .then(_ => this.annotate())
298         .then(_ => this.getCcParams())
299         .then(_ => {
300             return this.billing.applyPayment(
301                 this.patronId,
302                 this.patron().last_xact_id(),
303                 this.paymentType,
304                 payments,
305                 this.paymentNote,
306                 this.checkNumber,
307                 this.ccPaymentParams,
308                 this.convertChangeToCredit ? this.pendingChange() : null
309             );
310         })
311         .then(resp => {
312             this.worklog.record({
313                 user: this.patron().family_name(),
314                 patron_id: this.patron().id(),
315                 amount: this.pendingPayment(),
316                 action: 'paid_bill'
317             });
318             this.patron().last_xact_id(resp.last_xact_id);
319             return this.handlePayReceipt(payments, resp.payments);
320         })
321
322         .then(_ => this.context.refreshPatron())
323
324         // refresh affected xact IDs
325         .then(_ => this.billGrid.reload())
326
327         .then(_ => {
328             this.paymentAmount = null;
329             this.focusPayAmount();
330         })
331
332         .catch(msg => console.debug('Payment Canceled:', msg))
333         .finally(() => {
334             this.applyingPayment = false;
335             this.refunding = false;
336         });
337     }
338
339     compilePayments(): Array<Array<number>> { // [ [xactId, payAmount], ... ]
340         const payments = [];
341         this.gridDataSource.data.forEach(row => {
342             if (row.paymentPending) {
343                 payments.push([row.id, row.paymentPending]);
344             }
345         });
346         return payments;
347     }
348
349     amountExceedsMax(): boolean {
350         if (this.paymentAmount < this.maxPayAmount) { return false; }
351         this.maxPayDialog.open().toPromise().then(_ => this.focusPayAmount());
352         return true;
353     }
354
355     // Credit card info
356     getCcParams(): Promise<any> {
357         if (this.paymentType !== 'credit_card_payment') {
358             return Promise.resolve();
359         }
360
361         return this.creditCardDialog.open().toPromise().then(ccArgs => {
362             if (ccArgs) {
363                 this.ccPaymentParams = ccArgs;
364             } else {
365                 return Promise.reject('CC dialog canceled');
366             }
367         });
368     }
369
370     verifyPayAmount(): Promise<any> {
371         if (this.paymentAmount < this.warnPayAmount) {
372             return Promise.resolve();
373         }
374
375         return this.warnPayDialog.open().toPromise().then(confirmed => {
376             if (!confirmed) {
377                 return Promise.reject('Pay amount not confirmed');
378             }
379         });
380     }
381
382     annotate(): Promise<any> {
383         if (!this.annotatePayment) { return Promise.resolve(); }
384
385         return this.annotateDialog.open().toPromise()
386         .then(value => {
387             if (!value) {
388                 // TODO: there is no way in PromptDialog to
389                 // differentiate between canceling the dialog and
390                 // submitting the dialog with no value.  In this case,
391                 // if the dialog is submitted with no value, we may want
392                 // to leave the dialog open so a value can be applied.
393                 return Promise.reject('No annotation supplied');
394             }
395             this.paymentNote = value;
396         });
397     }
398
399     updatePendingColumn() {
400
401         // Reset...
402         this.gridDataSource.data.forEach(row => row.paymentPending = 0);
403
404         let amount = this.pendingPayment();
405         let done = false;
406
407         this.billGrid.context.rowSelector.selected().forEach(index => {
408             if (done) { return; }
409
410             const row = this.billGrid.context.getRowByIndex(index);
411             const owed = Number(row.balance_owed);
412
413             if (amount > owed) {
414                 // Pending payment amount exceeds balance of this
415                 // row.  Pay the entire amount
416                 row.paymentPending = owed;
417                 amount -= owed;
418
419             } else {
420                 // balance owed on the current item matches or exceeds
421                 // the pending payment.  Apply the full remainder of
422                 // the payment to this item... and we're done.
423                 //
424                 // Limit to two decimal places to avoid floating point
425                 // issues and cast back to number to match data type.
426                 row.paymentPending = Number(amount.toFixed(2));
427                 done = true;
428             }
429         });
430     }
431
432     printBills(rows: any[]) {
433         if (rows.length === 0) { return; }
434
435         this.printer.print({
436             templateName: 'bills_current',
437             contextData: {xacts: rows},
438             printContext: 'default'
439         });
440     }
441
442     handlePayReceipt(payments: Array<Array<number>>, paymentIds: number[]): Promise<any> {
443
444         if (this.disableAutoPrint || !this.receiptOnPayment) {
445             return Promise.resolve();
446         }
447
448         const pending = this.pendingPayment();
449         const prevBalance = this.context.summary.stats.fines.balance_owed;
450         const newBalance = (prevBalance * 100 - pending * 100) / 100;
451
452         const context = {
453             payments: [],
454             previous_balance: prevBalance,
455             new_balance: newBalance,
456             payment_type: this.paymentType,
457             payment_total: this.paymentAmount,
458             payment_applied: pending,
459             amount_voided: this.sessionVoided,
460             change_given: this.pendingChange(),
461             payment_note: this.paymentNote
462         };
463
464         payments.forEach(payment => {
465
466             const entry =
467                 this.gridDataSource.data.filter(e => e.id === payment[0])[0];
468
469             context.payments.push({
470                 amount: payment[1],
471                 xact: entry,
472                 title: entry.title,
473                 copy_barcode: entry.copy_barcode
474             });
475         });
476
477         // The print service protects against multiple print attempts
478         // firing at once, so it's OK to fire these in quick succession.
479         range(1, this.numReceipts).subscribe(_ => {
480             this.printer.print({
481                 templateName: 'bills_payment',
482                 contextData: context,
483                 printContext: 'receipt'
484             });
485         });
486     }
487
488     selectRefunds() {
489         this.billGrid.context.rowSelector.clear();
490         this.gridDataSource.data.forEach(row => {
491             if (row.balance_owed < 0) {
492                 this.billGrid.context.toggleSelectOneRow(row.id);
493             }
494         });
495     }
496
497     addBilling() {
498         this.billingDialog.newXact = true;
499         this.billingDialog.open().subscribe(data => {
500             if (data) {
501                 this.context.refreshPatron().then(_ => this.billGrid.reload());
502             }
503         });
504     }
505
506     addBillingForXact(rows: any[]) {
507         if (rows.length === 0) { return; }
508         const xactIds = rows.map(r => r.id);
509
510         this.billingDialog.newXact = false;
511         const xactsChanged = [];
512
513         from(xactIds)
514         .pipe(concatMap(id => {
515             this.billingDialog.xactId = id;
516             return this.billingDialog.open();
517         }))
518         .pipe(tap(data => {
519             if (data) {
520                 xactsChanged.push(data.xactId);
521             }
522         }))
523         .subscribe(null, null, () => {
524             if (xactsChanged.length > 0) {
525                 this.billGrid.reload();
526             }
527         });
528     }
529
530     voidBillings(rows: any[]) {
531         if (rows.length === 0) { return; }
532
533         const xactIds = rows.map(r => r.id);
534         const billIds = [];
535         let cents = 0;
536
537         from(xactIds)
538         // Grab the billings
539         .pipe(concatMap(xactId => {
540             return this.pcrud.search('mb', {xact: xactId}, {}, {authoritative: true})
541             .pipe(tap(billing => {
542                 if (billing.voided() === 'f') {
543                     cents += billing.amount() * 100;
544                     billIds.push(billing.id());
545                 }
546             }));
547         }))
548         // Confirm the void action
549         .pipe(concatMap(_ => {
550             this.voidAmount = cents / 100;
551             return this.voidBillsDialog.open();
552         }))
553         // Do the void
554         .pipe(concatMap(confirmed => {
555             if (!confirmed) { return empty(); }
556
557             return this.net.requestWithParamList(
558                 'open-ils.circ',
559                 'open-ils.circ.money.billing.void',
560                 [this.auth.token()].concat(billIds) // positional params
561             );
562         }))
563         // Clean up and refresh data
564         .subscribe(resp => {
565             if (!resp || this.reportError(resp)) { return; }
566
567             this.sessionVoided = (this.sessionVoided * 100 + cents) / 100;
568             this.voidAmount = 0;
569
570             this.context.refreshPatron()
571             .then(_ => this.billGrid.reload());
572         });
573     }
574
575     adjustToZero(rows: any[]) {
576         if (rows.length === 0) { return; }
577         const xactIds = rows.map(r => r.id);
578
579         this.audio.play('warning.circ.adjust_to_zero_confirmation');
580
581         this.adjustToZeroDialog.open().subscribe(confirmed => {
582             if (!confirmed) { return; }
583
584             this.net.request(
585                 'open-ils.circ',
586                 'open-ils.circ.money.billable_xact.adjust_to_zero',
587                 this.auth.token(), xactIds
588             ).subscribe(resp => {
589                 if (!this.reportError(resp)) {
590                     this.context.refreshPatron()
591                     .then(_ => this.billGrid.reload());
592                 }
593             });
594         });
595     }
596
597     // Returns true if the value was an (error) event
598     reportError(value: any): boolean {
599         const evt = this.evt.parse(value);
600         if (evt) {
601             console.error(evt + '');
602             console.error(evt);
603             this.toast.danger(evt + '');
604             return true;
605         }
606         return false;
607     }
608
609     // This is functionally equivalent to selecting a neg. transaction
610     // then clicking Apply Payment -- this just adds a speed bump (ditto
611     // the XUL client).
612     refund(rows: any[]) {
613         if (rows.length === 0) { return; }
614         const xactIds = rows.map(r => r.id);
615
616         this.refundDialog.open().subscribe(confirmed => {
617             if (!confirmed) { return; }
618             this.refunding = true; // clearen in applyPayment()
619             this.paymentAmount = null;
620         });
621     }
622
623     showStatement(row: any) {
624         if (!row) { return; }
625         this.router.navigate(['/staff/circ/patron',
626             this.patronId, 'bills', row.id, 'statement']);
627     }
628 }
629