evergreen/common/
billing.rs

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
22/// Void a list of billings.
23pub fn void_bills(
24    editor: &mut Editor,
25    billing_ids: &[i64], // money.billing.id
26    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
76/// Sets or clears xact_finish on a transaction as needed.
77pub 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    // See if we have a completed circ.
89    let no_circ_or_complete = match editor.retrieve("circ", xact_id)? {
90        Some(c) => c["stop_fines"].is_string(), // otherwise is_null()
91        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            // If nothing is owed on the transaction, but it is still open,
100            // and this transaction is not an open circulation, close it.
101
102            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        // Transaction closed but money or refund still owed.
108
109        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
119/// Returns the context org unit ID for a transaction (by ID).
120pub fn xact_org(editor: &mut Editor, xact_id: i64) -> EgResult<i64> {
121    // There's a view for that!
122    // money.billable_xact_summary_location_view
123    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
130/// Creates and returns a newly created money.billing.
131pub 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
162/// Void a set of bills (by type) for a transaction or apply
163/// adjustments to zero the bills, depending on settings, etc.
164pub 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    // "lost" settings are checked first for backwards compat /
187    // consistency with Perl.
188    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(), &note)
211    } else {
212        let note = format!("System: VOIDED {for_note}");
213        void_bills(editor, bill_ids.as_slice(), Some(&note))
214    }
215}
216
217/// Assumes all bills are linked to the same transaction.
218pub 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(()), // should never happen
248    };
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, // should never happen
254        };
255
256        // The amount to adjust is the non-adjusted balance on the
257        // bill. It should never be less than zero.
258        let mut amount_to_adjust = util::fpdiff(map.bill_amount, map.adjustment_amount);
259
260        // Check if this bill is already adjusted.  We don't allow
261        // "double" adjustments regardless of settings.
262        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        // Create the account adjustment
271        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        // Adjust our bill_payment_map
286        map.adjustment_amount += amount_to_adjust;
287        map.adjustments.push(payment);
288
289        // Should come to zero:
290        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    /// The adjusted bill object
304    pub bill: EgValue,
305    /// List of account adjustments that apply directly to the bill.
306    pub adjustments: Vec<EgValue>,
307    /// List of payment objects applied to the bill
308    pub payments: Vec<EgValue>,
309    /// original amount from the billing object
310    pub bill_amount: f64,
311    /// Total of account adjustments that apply to the bill.
312    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        // If we have no payments, return the unmodified maps.
367        return Ok(maps);
368    }
369
370    // Sort payments largest to lowest amount.
371    // This will come in handy later.
372    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        // Find adjustments that apply to this individual billing and
386        // has not already been accounted for.
387        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                // It should never happen that we have more adjustment
412                // payments on a single bill than the amount of the bill.
413
414                // Clone the adjustment to say how much of it actually
415                // applied to this bill.
416                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    // Try to map payments to bills by amounts starting with the
432    // largest payments.
433    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    // Remove the used payments from our working list.
448    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    // Map remaining bills to payments in whatever order.
459    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        // Loop over remaining unused / unmapped payments.
467        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
499/// Returns true if the most recent payment toward a transaction
500/// occurred within now minus the specified interval.
501pub 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    // Every payment has a payment_ts value
525    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, // grace period
561    )
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
586/// Group some of these params into a struct so we can simplify the API
587/// and make clippy happy.
588///
589/// Just the Copy-able values for now so we're not to_string()'ing
590/// just to store temporary values.
591pub 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    // TODO add the bit about reservation time zone offsets
627
628    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    // Determine the billing period of the next fine to generate
658    // based on the billing time of the most recent fine *which
659    // occurred after the current due date*.  Otherwise, when a
660    // due date changes, the fine generator will back-fill billings
661    // for a period of time where the item was not technically overdue.
662    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    // First fine in the list (if we have one) will be the most recent.
670    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            // If we have no fines, due date is the last fine time.
682            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        // We have no fines yet and we have a grace period and we
696        // are still within the grace period.  New fines not yet needed.
697
698        log::info!("Stil within grace period for circ {xact_id}");
699        return Ok(());
700    }
701
702    // Generate fines for each past interval, including the one we are inside.
703    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        // No fines to generate.
708        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        // Translate the last fine time to the timezone of the affected
740        // org unit so the org::next_open_date() calculation below
741        // can use the correct day / day of week information, which can
742        // vary across timezones.
743        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                // Avoid adding a fine if the org unit is closed
760                // on the day of the period_end date.
761                continue;
762            }
763        }
764
765        // The billing amount for this billing normally ought to be
766        // the recurring fine amount.  However, if the recurring fine
767        // amount would cause total fines to exceed the max fine amount,
768        // we may wish to reduce the amount for this billing (if
769        // circ.fines.truncate_to_max_fine is true).
770        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(); // required
792    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        // Only extended for >1day intervals.
808        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        // No extension configured.
826        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        // Merge closed dates trailing the grace period into the grace period.
835        // Note to self: why add exactly one day?
836        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        // Start checking the day after the item was due.
845        due_date = date::add_interval(due_date, "1 day")?;
846    } else {
847        // Jump to the end of the grace period.
848        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            // No need to extend the grace period if the org unit
857            // is never open or it's open on the calculated due date;
858            return Ok(grace_period);
859        }
860        org::OrgOpenState::OpensOnDate(d) => d,
861    };
862
863    // Extend the due date out (using seconds instead of whole days),
864    // until the due date occurs on the next open day.
865    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        // Nothing to void/zero.
907        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
941/// Determine the minimum overdue billing date that can be voided,
942/// based on the provided backdate.
943///
944/// Fines for overdue materials are assessed up to, but not including,
945/// one fine interval after the fines are applicable.  Here, we add
946/// one fine interval to the backdate to ensure that we are not
947/// voiding fines that were applicable before the backdate.
948fn 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
981/// Get the numeric cost of a copy, honoring various org settings
982/// for which field to pull the cost from and how to handle zero/unset
983/// cost values.
984pub 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    // Retain the price as a json value for now because null is important.
1031    let mut price = if primary_field == "cost" {
1032        &copy["cost"]
1033    } else {
1034        &copy["price"]
1035    };
1036
1037    if (price.is_null() || (price.float()? == 0.0 && charge_on_zero)) && !secondary_field.is_empty()
1038    {
1039        price = &copy[secondary_field];
1040    }
1041
1042    // Fall back to legacy item cost calculation
1043    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    // Now we want numbers
1054    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        // Only let $0 fall through if charge_on_zero is explicitly false.
1062        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}