1use crate as eg;
2use chrono::Duration;
3use eg::common::org;
4use eg::common::penalty;
5use eg::common::settings::Settings;
6use eg::constants as C;
7use eg::date;
8use eg::editor::Editor;
9use eg::result::EgResult;
10use eg::util;
11use eg::EgValue;
12use std::cmp::Ordering;
13use std::collections::HashSet;
14
15const DAY_OF_SECONDS: i64 = 86400;
16
17pub struct BillingType {
18 pub label: String,
19 pub id: i64,
20}
21
22pub fn void_bills(
24 editor: &mut Editor,
25 billing_ids: &[i64], maybe_note: Option<&str>,
27) -> EgResult<()> {
28 let mut bills = editor.search("mb", eg::hash! {"id": billing_ids})?;
29 let mut penalty_users: HashSet<(i64, i64)> = HashSet::new();
30
31 if bills.is_empty() {
32 Err(format!("No such billings: {billing_ids:?}"))?;
33 }
34
35 for mut bill in bills.drain(0..) {
36 if bill["voided"].boolish() {
37 log::debug!("Billing {} already voided. Skipping", bill["id"]);
38 continue;
39 }
40
41 let xact = editor.retrieve("mbt", bill["xact"].clone())?;
42 let xact = match xact {
43 Some(x) => x,
44 None => Err(editor.die_event())?,
45 };
46
47 let xact_org = xact_org(editor, xact.id()?)?;
48 let xact_user = xact["usr"].int()?;
49 let xact_id = xact.id()?;
50
51 penalty_users.insert((xact_user, xact_org));
52
53 bill["voided"] = "t".into();
54 bill["voider"] = editor.requestor_id()?.into();
55 bill["void_time"] = "now".into();
56
57 if let Some(orig_note) = bill["note"].as_str() {
58 if let Some(new_note) = maybe_note {
59 bill["note"] = format!("{}\n{}", orig_note, new_note).into();
60 }
61 } else if let Some(new_note) = maybe_note {
62 bill["note"] = new_note.into();
63 }
64
65 editor.update(bill)?;
66 check_open_xact(editor, xact_id)?;
67 }
68
69 for (user_id, org_id) in penalty_users.iter() {
70 penalty::calculate_penalties(editor, *user_id, *org_id, None)?;
71 }
72
73 Ok(())
74}
75
76pub fn check_open_xact(editor: &mut Editor, xact_id: i64) -> EgResult<()> {
78 let mut xact = match editor.retrieve("mbt", xact_id)? {
79 Some(x) => x,
80 None => Err(editor.die_event())?,
81 };
82
83 let mbts = match editor.retrieve("mbts", xact_id)? {
84 Some(m) => m,
85 None => Err(editor.die_event())?,
86 };
87
88 let no_circ_or_complete = match editor.retrieve("circ", xact_id)? {
90 Some(c) => c["stop_fines"].is_string(), None => true,
92 };
93
94 let zero_owed = mbts["balance_owed"].float()? == 0.0;
95 let xact_open = xact["xact_finish"].is_null();
96
97 if zero_owed {
98 if xact_open && no_circ_or_complete {
99 log::info!("Closing completed transaction {xact_id} on zero balance");
103 xact["xact_finish"] = "now".into();
104 return editor.update(xact);
105 }
106 } else if !xact_open {
107 if !zero_owed && !xact_open {
110 log::info!("Re-opening transaction {xact_id} on non-zero balance");
111 xact["xact_finish"] = EgValue::Null;
112 return editor.update(xact);
113 }
114 }
115
116 Ok(())
117}
118
119pub fn xact_org(editor: &mut Editor, xact_id: i64) -> EgResult<i64> {
121 if let Some(sum) = editor.retrieve("mbtslv", xact_id)? {
124 sum["billing_location"].int()
125 } else {
126 Err(format!("No Such Transaction: {xact_id}").into())
127 }
128}
129
130pub fn create_bill(
132 editor: &mut Editor,
133 amount: f64,
134 btype: BillingType,
135 xact_id: i64,
136 maybe_note: Option<&str>,
137 period_start: Option<&str>,
138 period_end: Option<&str>,
139) -> EgResult<EgValue> {
140 log::info!(
141 "System is charging ${amount} [btype={}:{}] on xact {xact_id}",
142 btype.id,
143 btype.label
144 );
145
146 let note = maybe_note.unwrap_or("SYSTEM GENERATED");
147
148 let bill = eg::hash! {
149 "xact": xact_id,
150 "amount": amount,
151 "period_start": period_start,
152 "period_end": period_end,
153 "billing_type": btype.label.as_str(),
154 "btype": btype.id,
155 "note": note,
156 };
157
158 let bill = EgValue::create("mb", bill)?;
159 editor.create(bill)
160}
161
162pub fn void_or_zero_bills_of_type(
165 editor: &mut Editor,
166 xact_id: i64,
167 context_org: i64,
168 btype_id: i64,
169 for_note: &str,
170) -> EgResult<()> {
171 log::info!("Void/Zero Bills for xact={xact_id} and btype={btype_id}");
172
173 let mut settings = Settings::new(editor);
174 let query = eg::hash! {"xact": xact_id, "btype": btype_id};
175 let bills = editor.search("mb", query)?;
176
177 if bills.is_empty() {
178 return Ok(());
179 }
180
181 let bill_ids: Vec<i64> = bills
182 .iter()
183 .map(|b| b.id().expect("Billing has ID"))
184 .collect();
185
186 let prohibit_neg_balance = settings
189 .get_value_at_org("bill.prohibit_negative_balance_on_lost", context_org)?
190 .boolish()
191 || settings
192 .get_value_at_org("bill.prohibit_negative_balance_default", context_org)?
193 .boolish();
194
195 let mut neg_balance_interval =
196 settings.get_value_at_org("bill.negative_balance_interval_on_lost", context_org)?;
197
198 if neg_balance_interval.is_null() {
199 neg_balance_interval =
200 settings.get_value_at_org("bill.negative_balance_interval_default", context_org)?;
201 }
202
203 let mut has_refundable = false;
204 if let Some(interval) = neg_balance_interval.as_str() {
205 has_refundable = xact_has_payment_within(editor, xact_id, interval)?;
206 }
207
208 if prohibit_neg_balance && !has_refundable {
209 let note = format!("System: ADJUSTED {for_note}");
210 adjust_bills_to_zero(editor, bill_ids.as_slice(), ¬e)
211 } else {
212 let note = format!("System: VOIDED {for_note}");
213 void_bills(editor, bill_ids.as_slice(), Some(¬e))
214 }
215}
216
217pub fn adjust_bills_to_zero(editor: &mut Editor, bill_ids: &[i64], note: &str) -> EgResult<()> {
219 let mut bills = editor.search("mb", eg::hash! {"id": bill_ids})?;
220 if bills.is_empty() {
221 return Ok(());
222 }
223
224 let xact_id = bills[0]["xact"].int()?;
225
226 let flesh = eg::hash! {
227 "flesh": 2,
228 "flesh_fields": {
229 "mbt": ["grocery", "circulation"],
230 "circ": ["target_copy"]
231 }
232 };
233
234 let mbt = editor
235 .retrieve_with_ops("mbt", xact_id, flesh)?
236 .expect("Billing has no transaction?");
237
238 let user_id = mbt["usr"].int()?;
239 let mut bill_maps = bill_payment_map_for_xact(editor, xact_id)?;
240
241 let xact_total = match bill_maps
242 .iter()
243 .map(|m| m.bill["amount"].float().unwrap())
244 .reduce(|a, b| a + b)
245 {
246 Some(t) => t,
247 None => return Ok(()), };
249
250 for bill in bills.iter_mut() {
251 let map = match bill_maps.iter_mut().find(|m| m.bill["id"] == bill["id"]) {
252 Some(m) => m,
253 None => continue, };
255
256 let mut amount_to_adjust = util::fpdiff(map.bill_amount, map.adjustment_amount);
259
260 if amount_to_adjust <= 0.0 {
263 continue;
264 }
265
266 if amount_to_adjust > xact_total {
267 amount_to_adjust = xact_total;
268 }
269
270 let payment = eg::hash! {
272 "amount": amount_to_adjust,
273 "amount_collected": amount_to_adjust,
274 "xact": xact_id,
275 "accepting_usr": editor.requestor_id()?,
276 "payment_ts": "now",
277 "billing": bill["id"].clone(),
278 "note": note,
279 };
280
281 let payment = EgValue::create("maa", payment)?;
282
283 let payment = editor.create(payment)?;
284
285 map.adjustment_amount += amount_to_adjust;
287 map.adjustments.push(payment);
288
289 let new_bill_amount = util::fpdiff(bill["amount"].float()?, amount_to_adjust);
291 bill["amount"] = new_bill_amount.into();
292 }
293
294 check_open_xact(editor, xact_id)?;
295
296 let org_id = xact_org(editor, xact_id)?;
297 penalty::calculate_penalties(editor, user_id, org_id, None)?;
298
299 Ok(())
300}
301
302pub struct BillPaymentMap {
303 pub bill: EgValue,
305 pub adjustments: Vec<EgValue>,
307 pub payments: Vec<EgValue>,
309 pub bill_amount: f64,
311 pub adjustment_amount: f64,
313}
314
315pub fn bill_payment_map_for_xact(
316 editor: &mut Editor,
317 xact_id: i64,
318) -> EgResult<Vec<BillPaymentMap>> {
319 let query = eg::hash! {
320 "xact": xact_id,
321 "voided": "f",
322 };
323 let ops = eg::hash! {
324 "order_by": {
325 "mb": {
326 "billing_ts": {
327 "direction": "asc"
328 }
329 }
330 }
331 };
332
333 let mut bills = editor.search_with_ops("mb", query, ops)?;
334
335 let mut maps = Vec::new();
336
337 if bills.is_empty() {
338 return Ok(maps);
339 }
340
341 for bill in bills.drain(0..) {
342 let amount = bill["amount"].float()?;
343
344 let map = BillPaymentMap {
345 bill,
346 adjustments: Vec::new(),
347 payments: Vec::new(),
348 bill_amount: amount,
349 adjustment_amount: 0.00,
350 };
351
352 maps.push(map);
353 }
354
355 let query = eg::hash! {"xact": xact_id, "voided": "f"};
356
357 let ops = eg::hash! {
358 "flesh": 1,
359 "flesh_fields": {"mp": ["account_adjustment"]},
360 "order_by": {"mp": {"payment_ts": {"direction": "asc"}}},
361 };
362
363 let mut payments = editor.search_with_ops("mp", query, ops)?;
364
365 if payments.is_empty() {
366 return Ok(maps);
368 }
369
370 payments.sort_by(|a, b| {
373 if b["amount"].float().unwrap() < a["amount"].float().unwrap() {
374 Ordering::Less
375 } else {
376 Ordering::Greater
377 }
378 });
379
380 let mut used_adjustments: HashSet<i64> = HashSet::new();
381
382 for map in maps.iter_mut() {
383 let bill = &mut map.bill;
384
385 let mut my_adjustments: Vec<&mut EgValue> = payments
388 .iter_mut()
389 .filter(|p| p["payment_type"].as_str().unwrap() == "account_adjustment")
390 .filter(|p| used_adjustments.contains(&p["account_adjustment"].id().unwrap()))
391 .filter(|p| p["account_adjustment"]["billing"] == bill["id"])
392 .map(|p| &mut p["account_adjustment"])
393 .collect();
394
395 if my_adjustments.is_empty() {
396 continue;
397 }
398
399 for adjustment in my_adjustments.drain(0..) {
400 let adjust_amount = adjustment["amount"].float()?;
401 let adjust_id = adjustment["id"].int()?;
402
403 let new_amount = util::fpdiff(bill["amount"].float()?, adjust_amount);
404
405 if new_amount >= 0.0 {
406 map.adjustments.push(adjustment.clone());
407 map.adjustment_amount += adjust_amount;
408 bill["amount"] = new_amount.into();
409 used_adjustments.insert(adjust_id);
410 } else {
411 let mut new_adjustment = adjustment.clone();
417 new_adjustment["amount"] = bill["amount"].clone();
418 new_adjustment["amount_collected"] = bill["amount"].clone();
419 map.adjustments.push(new_adjustment.clone());
420 map.adjustment_amount += new_adjustment["amount"].float()?;
421 bill["amount"] = 0.0.into();
422 adjustment["amount"] = EgValue::from(-new_amount);
423 }
424
425 if bill["amount"].float()? == 0.0 {
426 break;
427 }
428 }
429 }
430
431 let mut used_payments: HashSet<i64> = HashSet::new();
434 for payment in payments.iter() {
435 let map = match maps.iter_mut().find(|m| {
436 m.bill["amount"] == payment["amount"] && !used_payments.contains(&payment.id().unwrap())
437 }) {
438 Some(m) => m,
439 None => continue,
440 };
441
442 map.bill["amount"] = EgValue::from(0.0);
443 map.payments.push(payment.clone());
444 used_payments.insert(payment.id()?);
445 }
446
447 let mut new_payments = Vec::new();
449 for pay in payments.drain(0..) {
450 if !used_payments.contains(&pay.id()?) {
451 new_payments.push(pay);
452 }
453 }
454
455 payments = new_payments;
456 let mut used_payments = HashSet::new();
457
458 for map in maps.iter_mut() {
460 let bill = &mut map.bill;
461
462 if bill["amount"].float()? <= 0.0 {
463 continue;
464 }
465
466 for pay in payments.iter_mut() {
468 let pay_id = pay.id()?;
469
470 if used_payments.contains(&pay_id) {
471 continue;
472 }
473
474 let bill_amount = bill["amount"].float()?;
475
476 if bill_amount <= 0.0 {
477 break;
478 }
479
480 let new_amount = util::fpdiff(bill_amount, pay["amount"].float()?);
481
482 if new_amount < 0.0 {
483 let mut new_payment = pay.clone();
484 new_payment["amount"] = EgValue::from(bill_amount);
485 bill["amount"] = EgValue::from(0.0);
486 map.payments.push(new_payment);
487 pay["amount"] = EgValue::from(-new_amount);
488 } else {
489 bill["amount"] = EgValue::from(new_amount);
490 map.payments.push(pay.clone());
491 used_payments.insert(pay_id);
492 }
493 }
494 }
495
496 Ok(maps)
497}
498
499pub fn xact_has_payment_within(
502 editor: &mut Editor,
503 xact_id: i64,
504 interval: &str,
505) -> EgResult<bool> {
506 let query = eg::hash! {
507 "xact": xact_id,
508 "payment_type": {"!=": "account_adjustment"}
509 };
510
511 let ops = eg::hash! {
512 "limit": 1,
513 "order_by": {"mp": "payment_ts DESC"}
514 };
515
516 let last_payment = editor.search_with_ops("mp", query, ops)?;
517
518 if last_payment.is_empty() {
519 return Ok(false);
520 }
521
522 let payment = &last_payment[0];
523
524 let payment_ts = &payment["payment_ts"].as_str().unwrap();
526 let payment_dt = date::parse_datetime(payment_ts)?;
527
528 let window_start = date::subtract_interval(date::now(), interval)?;
529
530 Ok(payment_dt > window_start)
531}
532
533#[derive(Clone, PartialEq)]
534pub enum BillableTransactionType {
535 Circ,
536 Reservation,
537}
538
539pub fn generate_fines_for_resv(editor: &mut Editor, resv_id: i64) -> EgResult<()> {
540 let resv = editor
541 .retrieve("bresv", resv_id)?
542 .ok_or_else(|| editor.die_event())?;
543
544 let fine_interval = match resv["fine_interval"].as_str() {
545 Some(f) => f,
546 None => return Ok(()),
547 };
548
549 generate_fines_for_xact(
550 editor,
551 resv["end_time"].as_str().unwrap(),
552 fine_interval,
553 FineParams {
554 xact_id: resv_id,
555 circ_lib: resv["pickup_lib"].int()?,
556 recurring_fine: resv["fine_amount"].float()?,
557 max_fine: resv["max_fine"].float()?,
558 xact_type: BillableTransactionType::Reservation,
559 },
560 None, )
562}
563
564pub fn generate_fines_for_circ(editor: &mut Editor, circ_id: i64) -> EgResult<()> {
565 log::info!("Generating fines for circulation {circ_id}");
566
567 let circ = editor
568 .retrieve("circ", circ_id)?
569 .ok_or_else(|| editor.die_event())?;
570
571 generate_fines_for_xact(
572 editor,
573 circ["due_date"].as_str().unwrap(),
574 circ["fine_interval"].str()?,
575 FineParams {
576 xact_id: circ_id,
577 circ_lib: circ["circ_lib"].int()?,
578 recurring_fine: circ["recurring_fine"].float()?,
579 max_fine: circ["max_fine"].float()?,
580 xact_type: BillableTransactionType::Circ,
581 },
582 circ["grace_period"].as_str(),
583 )
584}
585
586pub struct FineParams {
592 xact_id: i64,
593 circ_lib: i64,
594 recurring_fine: f64,
595 max_fine: f64,
596 xact_type: BillableTransactionType,
597}
598
599pub fn generate_fines_for_xact(
600 editor: &mut Editor,
601 due_date: &str,
602 fine_interval: &str,
603 fine_params: FineParams,
604 grace_period: Option<&str>,
605) -> EgResult<()> {
606 let mut settings = Settings::new(editor);
607
608 let xact_id = fine_params.xact_id;
609 let circ_lib = fine_params.circ_lib;
610 let mut recurring_fine = fine_params.recurring_fine;
611 let mut max_fine = fine_params.max_fine;
612 let xact_type = fine_params.xact_type;
613
614 let fine_interval_secs = date::interval_to_seconds(fine_interval)?;
615 let mut grace_period = date::interval_to_seconds(grace_period.unwrap_or("0s"))?;
616 let now = date::now();
617
618 if fine_interval_secs == 0 || recurring_fine * 100.0 == 0.0 || max_fine * 100.0 == 0.0 {
619 log::info!(
620 "Fine generator skipping transaction {xact_id}
621 due to 0 fine interval, 0 fine rate, or 0 max fine."
622 );
623 return Ok(());
624 }
625
626 let query = eg::hash! {
629 "xact": xact_id,
630 "btype": C::BTYPE_OVERDUE_MATERIALS,
631 };
632
633 let ops = eg::hash! {
634 "flesh": 1,
635 "flesh_fields": {"mb": ["adjustments"]},
636 "order_by": {"mb": "billing_ts DESC"},
637 };
638
639 let mut fines = editor.search_with_ops("mb", query, ops)?;
640 let mut current_fine_total = 0.0;
641 for fine in fines.iter() {
642 if !fine["voided"].boolish() {
643 current_fine_total += fine["amount"].float()? * 100.0;
644 }
645 for adj in fine["adjustments"].members() {
646 if !adj["voided"].boolish() {
647 current_fine_total -= adj["amount"].float()? * 100.0;
648 }
649 }
650 }
651
652 log::info!(
653 "Fine total for transaction {xact_id} is {:.2}",
654 current_fine_total / 100.0
655 );
656
657 let fines: Vec<EgValue> = fines
663 .drain(..)
664 .filter(|f| f["billing_ts"].as_str().unwrap() > due_date)
665 .collect();
666
667 let due_date_dt = date::parse_datetime(due_date)?;
668
669 let last_fine_dt = match fines.first() {
671 Some(f) => date::parse_datetime(f["billing_ts"].as_str().unwrap())?,
672 None => {
673 grace_period = extend_grace_period(
674 editor,
675 circ_lib,
676 grace_period,
677 due_date_dt,
678 Some(&mut settings),
679 )?;
680
681 due_date_dt
683 }
684 };
685
686 if last_fine_dt > now {
687 log::warn!("Transaction {xact_id} has futuer last fine date?");
688 return Ok(());
689 }
690
691 if last_fine_dt == due_date_dt
692 && grace_period > 0
693 && now.timestamp() < due_date_dt.timestamp() - grace_period
694 {
695 log::info!("Stil within grace period for circ {xact_id}");
699 return Ok(());
700 }
701
702 let range = now.timestamp() - last_fine_dt.timestamp();
704 let pending_fine_count = (range as f64 / fine_interval_secs as f64).ceil() as i64;
705
706 if pending_fine_count == 0 {
707 return Ok(());
709 }
710
711 recurring_fine *= 100.0;
712 max_fine *= 100.0;
713
714 let skip_closed_check = settings
715 .get_value_at_org("circ.fines.charge_when_closed", circ_lib)?
716 .boolish();
717
718 let truncate_to_max_fine = settings
719 .get_value_at_org("circ.fines.truncate_to_max_fine", circ_lib)?
720 .boolish();
721
722 let timezone = settings
723 .get_value_at_org("lib.timezone", circ_lib)?
724 .as_str()
725 .unwrap_or("local");
726
727 for slot in 0..pending_fine_count {
728 if current_fine_total >= max_fine && xact_type == BillableTransactionType::Circ {
729 log::info!("Max fines reached for circulation {xact_id}");
730
731 if let Some(mut circ) = editor.retrieve("circ", xact_id)? {
732 circ["stop_fines"] = EgValue::from("MAXFINES");
733 circ["stop_fines_time"] = EgValue::from("now");
734 editor.update(circ)?;
735 break;
736 }
737 }
738
739 let mut period_end = date::set_timezone(last_fine_dt, timezone)?;
744
745 let mut current_bill_count = slot;
746 while current_bill_count > 0 {
747 period_end = date::add_interval(period_end, fine_interval)?;
748 current_bill_count -= 1;
749 }
750
751 let duration = Duration::try_seconds(fine_interval_secs - 1)
752 .ok_or_else(|| format!("Invalid interval {fine_interval}"))?;
753
754 let period_start = period_end - duration;
755
756 if !skip_closed_check {
757 let org_open_data = org::next_open_date(editor, circ_lib, &period_end)?;
758 if org_open_data != org::OrgOpenState::Open {
759 continue;
762 }
763 }
764
765 let mut this_billing_amount = recurring_fine;
771 if truncate_to_max_fine && (current_fine_total + this_billing_amount) > max_fine {
772 this_billing_amount = max_fine - current_fine_total;
773 }
774
775 current_fine_total += this_billing_amount;
776
777 let bill = eg::hash! {
778 xact: xact_id,
779 note: "System Generated Overdue Fine",
780 billing_type: "Overdue materials",
781 btype: C::BTYPE_OVERDUE_MATERIALS,
782 amount: this_billing_amount / 100.0,
783 period_start: date::to_iso(&period_start),
784 period_end: date::to_iso(&period_end),
785 };
786
787 let bill = EgValue::create("mb", bill)?;
788 editor.create(bill)?;
789 }
790
791 let xact = editor.retrieve("mbt", xact_id)?.unwrap(); let user_id = xact["usr"].int()?;
793
794 penalty::calculate_penalties(editor, user_id, circ_lib, None)?;
795
796 Ok(())
797}
798
799pub fn extend_grace_period(
800 editor: &mut Editor,
801 context_org: i64,
802 grace_period: i64,
803 mut due_date: date::EgDate,
804 settings: Option<&mut Settings>,
805) -> EgResult<i64> {
806 if grace_period < DAY_OF_SECONDS {
807 return Ok(grace_period);
809 }
810
811 let mut local_settings;
812 let settings = match settings {
813 Some(s) => s,
814 None => {
815 local_settings = Some(Settings::new(editor));
816 local_settings.as_mut().unwrap()
817 }
818 };
819
820 let extend = settings
821 .get_value_at_org("circ.grace.extend", context_org)?
822 .boolish();
823
824 if !extend {
825 return Ok(grace_period);
827 }
828
829 let extend_into_closed = settings
830 .get_value_at_org("circ.grace.extend.into_closed", context_org)?
831 .boolish();
832
833 if extend_into_closed {
834 due_date = date::add_interval(due_date, "1 day")?;
837 }
838
839 let extend_all = settings
840 .get_value_at_org("circ.grace.extend.all", context_org)?
841 .boolish();
842
843 if extend_all {
844 due_date = date::add_interval(due_date, "1 day")?;
846 } else {
847 due_date += Duration::try_seconds(grace_period)
849 .ok_or_else(|| format!("Invalid duration seconds: {grace_period}"))?;
850 }
851
852 let org_open_data = org::next_open_date(editor, context_org, &due_date)?;
853
854 let closed_until = match org_open_data {
855 org::OrgOpenState::Never | org::OrgOpenState::Open => {
856 return Ok(grace_period);
859 }
860 org::OrgOpenState::OpensOnDate(d) => d,
861 };
862
863 let mut new_grace_period = grace_period;
866 while due_date.date_naive() < closed_until.date_naive() {
867 new_grace_period += DAY_OF_SECONDS;
868 due_date = date::add_interval(due_date, "1 day")?;
869 }
870
871 Ok(new_grace_period)
872}
873
874pub fn void_or_zero_overdues(
875 editor: &mut Editor,
876 circ_id: i64,
877 backdate: Option<&str>,
878 mut note: Option<&str>,
879 force_zero: bool,
880 force_void: bool,
881) -> EgResult<()> {
882 log::info!("Voiding overdues for circ={circ_id}");
883
884 let circ = editor
885 .retrieve("circ", circ_id)?
886 .ok_or_else(|| editor.die_event())?;
887
888 let mut query = eg::hash! {
889 "xact": circ_id,
890 "btype": C::BTYPE_OVERDUE_MATERIALS,
891 };
892
893 if let Some(bd) = backdate {
894 if note.is_none() {
895 note = Some("System: OVERDUE REVERSED FOR BACKDATE");
896 }
897 if let Some(min_date) = calc_min_void_date(editor, &circ, bd)? {
898 query["billing_ts"] = eg::hash! {">=": date::to_iso(&min_date) };
899 }
900 }
901
902 let circ_lib = circ["circ_lib"].int()?;
903 let bills = editor.search("mb", query)?;
904
905 if bills.is_empty() {
906 return Ok(());
908 }
909
910 let bill_ids: Vec<i64> = bills.iter().map(|b| b.id().expect("Has ID")).collect();
911
912 let mut settings = Settings::new(editor);
913
914 let prohibit_neg_balance = settings
915 .get_value_at_org("bill.prohibit_negative_balance_on_overdue", circ_lib)?
916 .boolish()
917 || settings
918 .get_value_at_org("bill.prohibit_negative_balance_default", circ_lib)?
919 .boolish();
920
921 let mut neg_balance_interval =
922 settings.get_value_at_org("bill.negative_balance_interval_on_overdue", circ_lib)?;
923
924 if neg_balance_interval.is_null() {
925 neg_balance_interval =
926 settings.get_value_at_org("bill.negative_balance_interval_default", circ_lib)?;
927 }
928
929 let mut has_refundable = false;
930 if let Some(interval) = neg_balance_interval.as_str() {
931 has_refundable = xact_has_payment_within(editor, circ_id, interval)?;
932 }
933
934 if force_zero || (!force_void && prohibit_neg_balance && !has_refundable) {
935 adjust_bills_to_zero(editor, bill_ids.as_slice(), note.unwrap_or(""))
936 } else {
937 void_bills(editor, bill_ids.as_slice(), note)
938 }
939}
940
941fn calc_min_void_date(
949 editor: &mut Editor,
950 circ: &EgValue,
951 backdate: &str,
952) -> EgResult<Option<date::EgDate>> {
953 let fine_interval = circ["fine_interval"].str()?;
954 let backdate = date::parse_datetime(backdate)?;
955 let due_date = date::parse_datetime(circ["due_date"].str()?)?;
956
957 let grace_period = circ["grace_period"].as_str().unwrap_or("0s");
958 let grace_period = date::interval_to_seconds(grace_period)?;
959
960 let grace_period = extend_grace_period(
961 editor,
962 circ["circ_lib"].int()?,
963 grace_period,
964 due_date,
965 None,
966 )?;
967
968 let grace_duration = Duration::try_seconds(grace_period)
969 .ok_or_else(|| format!("Invalid duration seconds: {grace_period}"))?;
970
971 if backdate < due_date + grace_duration {
972 log::info!("Backdate {backdate} is within grace period, voiding all");
973 Ok(None)
974 } else {
975 let backdate = date::add_interval(backdate, fine_interval)?;
976 log::info!("Applying backdate {backdate} in overdue voiding");
977 Ok(Some(backdate))
978 }
979}
980
981pub fn get_copy_price(editor: &mut Editor, copy_id: i64) -> EgResult<f64> {
985 let flesh = eg::hash! {"flesh": 1, "flesh_fields": {"acp": ["call_number"]}};
986
987 let copy = editor
988 .retrieve_with_ops("acp", copy_id, flesh)?
989 .ok_or_else(|| editor.die_event())?;
990
991 let owner = if copy["call_number"].id()? == C::PRECAT_CALL_NUMBER {
992 copy["circ_lib"].int()?
993 } else {
994 copy["call_number"]["owning_lib"].int()?
995 };
996
997 let mut settings = Settings::new(editor);
998 settings.set_org_id(owner);
999
1000 settings.fetch_values(&[
1001 "circ.min_item_price",
1002 "circ.max_item_price",
1003 "circ.charge_lost_on_zero",
1004 "circ.primary_item_value_field",
1005 "circ.secondary_item_value_field",
1006 "cat.default_item_price",
1007 ])?;
1008
1009 let primary_field = match settings
1010 .get_value("circ.primary_item_value_field")?
1011 .as_str()
1012 {
1013 Some("cost") => "cost",
1014 Some("price") => "price",
1015 _ => "",
1016 };
1017
1018 let secondary_field = match settings
1019 .get_value("circ.secondary_item_value_field")?
1020 .as_str()
1021 {
1022 Some("cost") => "cost",
1023 Some("price") => "price",
1024 _ => "",
1025 };
1026
1027 let charge_on_zero_op = settings.get_value("circ.charge_lost_on_zero")?.as_bool();
1028 let charge_on_zero = charge_on_zero_op.unwrap_or_default();
1029
1030 let mut price = if primary_field == "cost" {
1032 ©["cost"]
1033 } else {
1034 ©["price"]
1035 };
1036
1037 if (price.is_null() || (price.float()? == 0.0 && charge_on_zero)) && !secondary_field.is_empty()
1038 {
1039 price = ©[secondary_field];
1040 }
1041
1042 let price_binding;
1044 if price.is_null() || (price.float()? == 0.0 && charge_on_zero) {
1045 let def_price = match settings.get_value("cat.default_item_price")?.as_f64() {
1046 Some(p) => p,
1047 _ => 0.0,
1048 };
1049 price_binding = Some(EgValue::from(def_price));
1050 price = price_binding.as_ref().unwrap();
1051 }
1052
1053 let mut price = price.float().unwrap_or(0.00);
1055
1056 if let Some(max_price) = settings.get_value("circ.max_item_price")?.as_f64() {
1057 if price > max_price {
1058 price = max_price;
1059 }
1060 } else if let Some(min_price) = settings.get_value("circ.min_item_price")?.as_f64() {
1061 if price < min_price && (price != 0.0 || charge_on_zero || charge_on_zero_op.is_none()) {
1063 price = min_price;
1064 }
1065 }
1066
1067 Ok(price)
1068}