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';
33 templateUrl: 'bills.component.html',
34 selector: 'eg-patron-bills',
35 styleUrls: ['bills.component.css']
37 export class BillsComponent implements OnInit, AfterViewInit {
39 @Input() patronId: number;
42 paymentType = 'cash_payment';
44 paymentAmount: number;
45 annotatePayment = false;
47 convertChangeToCredit = false;
48 receiptOnPayment = false;
49 applyingPayment = false;
51 ccPaymentParams: CreditCardPaymentParams;
52 disableAutoPrint = false;
54 maxPayAmount = 100000;
59 gridDataSource: GridDataSource = new GridDataSource();
60 cellTextGenerator: GridCellTextGenerator;
61 rowClassCallback: (row: any) => string;
62 rowFlairCallback: (row: any) => GridRowFlairEntry;
64 nowTime: number = new Date().getTime();
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;
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
99 this.cellTextGenerator = {
100 title: row => row.title,
101 copy_barcode: row => row.copy_barcode,
102 call_number: row => row.call_number_label
105 this.rowClassCallback = (row: any): string => {
106 if (row['circulation.stop_fines'] === 'LOST') {
108 } else if (row['circulation.stop_fines'] === 'LONGOVERDUE') {
109 return 'longoverdue-row';
110 } else if (this.circIsOverdue(row)) {
111 return 'less-intense-alert';
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'};
126 this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
131 balance_owed: {'<>' : 0}
134 return this.flatData.getRows(
135 this.billGrid.context, query, pager, sort)
137 row.paymentPending = 0;
138 row.billingLocation =
139 row['grocery.billing_location.shortname'] ||
140 row['circulation.circ_lib.shortname'];
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());
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/)) {
158 return (Date.parse(due) < this.nowTime);
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'
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'];
176 const noPrint = sets['circ.staff_client.do_not_auto_attempt_print'];
177 if (noPrint && noPrint.includes('Bill Pay')) {
178 this.disableAutoPrint = true;
183 applySetting(name: string, value: any) {
184 this.serverStore.setItem(name, value);
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());
193 this.focusPayAmount();
198 const node = document.getElementById('pay-amount') as HTMLInputElement;
199 if (node) { node.focus(); node.select(); }
203 patron(): IdlObject {
204 return this.context.summary ? this.context.summary.patron : null;
207 selectedPaymentInfo(): {owed: number, billed: number, paid: number} {
208 const info = {owed : 0, billed : 0, paid : 0};
210 if (!this.billGrid) { return info; } // page loading
212 this.billGrid.context.rowSelector.selected().forEach(id => {
213 const row = this.billGrid.context.getRowByIndex(id);
215 if (!row) { return; } // Called mid-reload
217 info.owed += Number(row.balance_owed) * 100;
218 info.billed += Number(row.total_owed) * 100;
219 info.paid += Number(row.total_paid) * 100;
230 pendingPaymentInfo(): {payment: number, change: number} {
232 const amt = this.paymentAmount || 0;
233 const owedSelected = this.owedSelected();
235 if (amt >= owedSelected) {
237 payment : owedSelected,
238 change : amt - owedSelected
242 return {payment : amt, change : 0};
245 disablePayment(): boolean {
246 if (!this.billGrid) { return true; } // still loading
249 this.applyingPayment ||
250 !this.pendingPayment() ||
251 this.paymentAmount === 0 ||
252 (this.paymentAmount < 0 && !this.refunding) ||
253 this.billGrid.context.rowSelector.selected().length === 0
257 refundsAvailable(): number {
259 this.gridDataSource.data.forEach(row => {
260 const balance = row.balance_owed;
261 if (balance < 0) { amount += balance * 100; }
265 return -(amount / 100);
268 paidSelected(): number {
269 return this.selectedPaymentInfo().paid;
272 owedSelected(): number {
273 return this.selectedPaymentInfo().owed;
276 billedSelected(): number {
277 return this.selectedPaymentInfo().billed;
280 pendingPayment(): number {
281 return this.pendingPaymentInfo().payment;
284 pendingChange(): number {
285 return this.pendingPaymentInfo().change;
289 if (this.amountExceedsMax()) { return; }
291 this.applyingPayment = true;
292 this.paymentNote = '';
293 this.ccPaymentParams = {};
294 const payments = this.compilePayments();
296 this.verifyPayAmount()
297 .then(_ => this.annotate())
298 .then(_ => this.getCcParams())
300 return this.billing.applyPayment(
302 this.patron().last_xact_id(),
307 this.ccPaymentParams,
308 this.convertChangeToCredit ? this.pendingChange() : null
312 this.worklog.record({
313 user: this.patron().family_name(),
314 patron_id: this.patron().id(),
315 amount: this.pendingPayment(),
318 this.patron().last_xact_id(resp.last_xact_id);
319 return this.handlePayReceipt(payments, resp.payments);
322 .then(_ => this.context.refreshPatron())
324 // refresh affected xact IDs
325 .then(_ => this.billGrid.reload())
328 this.paymentAmount = null;
329 this.focusPayAmount();
332 .catch(msg => console.debug('Payment Canceled:', msg))
334 this.applyingPayment = false;
335 this.refunding = false;
339 compilePayments(): Array<Array<number>> { // [ [xactId, payAmount], ... ]
341 this.gridDataSource.data.forEach(row => {
342 if (row.paymentPending) {
343 payments.push([row.id, row.paymentPending]);
349 amountExceedsMax(): boolean {
350 if (this.paymentAmount < this.maxPayAmount) { return false; }
351 this.maxPayDialog.open().toPromise().then(_ => this.focusPayAmount());
356 getCcParams(): Promise<any> {
357 if (this.paymentType !== 'credit_card_payment') {
358 return Promise.resolve();
361 return this.creditCardDialog.open().toPromise().then(ccArgs => {
363 this.ccPaymentParams = ccArgs;
365 return Promise.reject('CC dialog canceled');
370 verifyPayAmount(): Promise<any> {
371 if (this.paymentAmount < this.warnPayAmount) {
372 return Promise.resolve();
375 return this.warnPayDialog.open().toPromise().then(confirmed => {
377 return Promise.reject('Pay amount not confirmed');
382 annotate(): Promise<any> {
383 if (!this.annotatePayment) { return Promise.resolve(); }
385 return this.annotateDialog.open().toPromise()
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');
395 this.paymentNote = value;
399 updatePendingColumn() {
402 this.gridDataSource.data.forEach(row => row.paymentPending = 0);
404 let amount = this.pendingPayment();
407 this.billGrid.context.rowSelector.selected().forEach(index => {
408 if (done) { return; }
410 const row = this.billGrid.context.getRowByIndex(index);
411 const owed = Number(row.balance_owed);
414 // Pending payment amount exceeds balance of this
415 // row. Pay the entire amount
416 row.paymentPending = owed;
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.
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));
432 printBills(rows: any[]) {
433 if (rows.length === 0) { return; }
436 templateName: 'bills_current',
437 contextData: {xacts: rows},
438 printContext: 'default'
442 handlePayReceipt(payments: Array<Array<number>>, paymentIds: number[]): Promise<any> {
444 if (this.disableAutoPrint || !this.receiptOnPayment) {
445 return Promise.resolve();
448 const pending = this.pendingPayment();
449 const prevBalance = this.context.summary.stats.fines.balance_owed;
450 const newBalance = (prevBalance * 100 - pending * 100) / 100;
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
464 payments.forEach(payment => {
467 this.gridDataSource.data.filter(e => e.id === payment[0])[0];
469 context.payments.push({
473 copy_barcode: entry.copy_barcode
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(_ => {
481 templateName: 'bills_payment',
482 contextData: context,
483 printContext: 'receipt'
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);
498 this.billingDialog.newXact = true;
499 this.billingDialog.open().subscribe(data => {
501 this.context.refreshPatron().then(_ => this.billGrid.reload());
506 addBillingForXact(rows: any[]) {
507 if (rows.length === 0) { return; }
508 const xactIds = rows.map(r => r.id);
510 this.billingDialog.newXact = false;
511 const xactsChanged = [];
514 .pipe(concatMap(id => {
515 this.billingDialog.xactId = id;
516 return this.billingDialog.open();
520 xactsChanged.push(data.xactId);
523 .subscribe(null, null, () => {
524 if (xactsChanged.length > 0) {
525 this.billGrid.reload();
530 voidBillings(rows: any[]) {
531 if (rows.length === 0) { return; }
533 const xactIds = rows.map(r => r.id);
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());
548 // Confirm the void action
549 .pipe(concatMap(_ => {
550 this.voidAmount = cents / 100;
551 return this.voidBillsDialog.open();
554 .pipe(concatMap(confirmed => {
555 if (!confirmed) { return empty(); }
557 return this.net.requestWithParamList(
559 'open-ils.circ.money.billing.void',
560 [this.auth.token()].concat(billIds) // positional params
563 // Clean up and refresh data
565 if (!resp || this.reportError(resp)) { return; }
567 this.sessionVoided = (this.sessionVoided * 100 + cents) / 100;
570 this.context.refreshPatron()
571 .then(_ => this.billGrid.reload());
575 adjustToZero(rows: any[]) {
576 if (rows.length === 0) { return; }
577 const xactIds = rows.map(r => r.id);
579 this.audio.play('warning.circ.adjust_to_zero_confirmation');
581 this.adjustToZeroDialog.open().subscribe(confirmed => {
582 if (!confirmed) { return; }
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());
597 // Returns true if the value was an (error) event
598 reportError(value: any): boolean {
599 const evt = this.evt.parse(value);
601 console.error(evt + '');
603 this.toast.danger(evt + '');
609 // This is functionally equivalent to selecting a neg. transaction
610 // then clicking Apply Payment -- this just adds a speed bump (ditto
612 refund(rows: any[]) {
613 if (rows.length === 0) { return; }
614 const xactIds = rows.map(r => r.id);
616 this.refundDialog.open().subscribe(confirmed => {
617 if (!confirmed) { return; }
618 this.refunding = true; // clearen in applyPayment()
619 this.paymentAmount = null;
623 showStatement(row: any) {
624 if (!row) { return; }
625 this.router.navigate(['/staff/circ/patron',
626 this.patronId, 'bills', row.id, 'statement']);