evergreen/common/
checkout.rs

1use crate as eg;
2use eg::common::bib;
3use eg::common::billing;
4use eg::common::circulator::{CircOp, CircPolicy, Circulator, LEGACY_CIRC_EVENT_MAP};
5use eg::common::holds;
6use eg::common::noncat;
7use eg::common::org;
8use eg::common::penalty;
9use eg::constants as C;
10use eg::date;
11use eg::event::EgEvent;
12use eg::result::EgResult;
13use eg::EgValue;
14use std::time::Duration;
15
16/// Performs item checkins
17impl Circulator<'_> {
18    /// Checkout an item.
19    ///
20    /// Returns Ok(()) if the active transaction completed and should
21    /// (probably) be committed and Err(EgError) if the active
22    /// transaction should be rolled backed.
23    pub fn checkout(&mut self) -> EgResult<()> {
24        if self.circ_op == CircOp::Unset {
25            self.circ_op = CircOp::Checkout;
26        }
27        self.init()?;
28
29        log::info!("{self} starting checkout");
30
31        if self.patron.is_none() {
32            return self.exit_err_on_event_code("ACTOR_USER_NOT_FOUND");
33        }
34
35        self.base_checkout_perms()?;
36
37        self.set_circ_policy()?;
38        self.inspect_policy_failures()?;
39        self.check_copy_alerts()?;
40        self.try_override_events()?;
41
42        if self.is_inspect() {
43            return Ok(());
44        }
45
46        if self.is_noncat {
47            return self.checkout_noncat();
48        }
49
50        if self.precat_requested() {
51            self.create_precat_copy()?;
52        } else if self.is_precat_copy() && !self.is_renewal() {
53            self.exit_err_on_event_code("ITEM_NOT_CATALOGED")?;
54        }
55
56        self.basic_copy_checks()?;
57        self.set_item_deposit_events()?;
58        self.check_captured_hold()?;
59        self.check_copy_status()?;
60        self.handle_claims_returned()?;
61        self.check_for_open_circ()?;
62
63        self.try_override_events()?;
64
65        // We've tested everything we can.  Build the circulation.
66
67        self.build_checkout_circ()?;
68        self.apply_due_date()?;
69        self.save_checkout_circ()?;
70        self.apply_limit_groups()?;
71
72        self.apply_deposit_fee()?;
73        self.handle_checkout_holds()?;
74
75        penalty::calculate_penalties(self.editor, self.patron_id, self.circ_lib, None)?;
76
77        self.build_checkout_response()
78    }
79
80    /// Perms that are always needed for checkout.
81    fn base_checkout_perms(&mut self) -> EgResult<()> {
82        let cl = self.circ_lib;
83
84        if !self.is_renewal() && !self.editor().allowed_at("COPY_CHECKOUT", cl)? {
85            return Err(self.editor().die_event());
86        }
87
88        if self.patron_id != self.editor().requestor_id()? {
89            // Users are allowed to "inspect" their own data.
90            if !self.editor().allowed_at("VIEW_PERMIT_CHECKOUT", cl)? {
91                return Err(self.editor().die_event());
92            }
93        }
94
95        Ok(())
96    }
97
98    fn checkout_noncat(&mut self) -> EgResult<()> {
99        let noncat_type = match self.options.get("noncat_type") {
100            Some(v) => v,
101            None => return Err("noncat_type required".into()),
102        };
103
104        let circ_lib = match self.options.get("noncat_circ_lib") {
105            Some(cl) => cl.int()?,
106            None => self.circ_lib,
107        };
108
109        let count = match self.options.get("noncat_count") {
110            Some(c) => c.int()?,
111            None => 1,
112        };
113
114        let mut checkout_time = None;
115        if let Some(ct) = self.options.get("checkout_time") {
116            if let Some(ct2) = ct.as_str() {
117                checkout_time = Some(ct2.to_string());
118            }
119        }
120
121        let patron_id = self.patron_id;
122        let noncat_type = noncat_type.int()?;
123
124        let mut circs = noncat::checkout(
125            self.editor(),
126            patron_id,
127            noncat_type,
128            circ_lib,
129            count,
130            checkout_time.as_deref(),
131        )?;
132
133        let mut evt = EgEvent::success();
134        if let Some(c) = circs.pop() {
135            // Perl API only returns the last created circulation
136            evt.set_payload(eg::hash! {"noncat_circ": c});
137        }
138        self.add_event(evt);
139
140        Ok(())
141    }
142
143    fn create_precat_copy(&mut self) -> EgResult<()> {
144        if !self.is_renewal() && !self.editor().allowed("CREATE_PRECAT")? {
145            return Err(self.editor().die_event());
146        }
147
148        // We already have a matching precat copy.
149        // Update so we can reuse it.
150        if self.copy.is_some() {
151            return self.update_existing_precat();
152        }
153
154        let reqr_id = self.requestor_id()?;
155
156        let dummy_title = self
157            .options
158            .get("dummy_title")
159            .map(|dt| dt.as_str())
160            .unwrap_or(Some(""))
161            .unwrap();
162
163        let dummy_author = self
164            .options
165            .get("dummy_author")
166            .map(|dt| dt.as_str())
167            .unwrap_or(Some(""))
168            .unwrap();
169
170        let dummy_isbn = self
171            .options
172            .get("dummy_isbn")
173            .map(|dt| dt.as_str())
174            .unwrap_or(Some(""))
175            .unwrap();
176
177        let circ_modifier = self
178            .options
179            .get("circ_modifier")
180            .map(|m| m.as_str())
181            .unwrap_or(Some(""))
182            .unwrap();
183
184        // Barcode required to get this far.
185        let copy_barcode = self.copy_barcode.as_deref().unwrap();
186
187        log::info!("{self} creating new pre-cat copy {copy_barcode}");
188
189        let copy = eg::hash! {
190            "circ_lib": self.circ_lib,
191            "creator": reqr_id,
192            "editor": reqr_id,
193            "barcode": copy_barcode,
194            "dummy_title": dummy_title,
195            "dummy_author": dummy_author,
196            "dummy_isbn": dummy_isbn,
197            "circ_modifier": circ_modifier,
198            "call_number": C::PRECAT_CALL_NUMBER,
199            "loan_duration": C::PRECAT_COPY_LOAN_DURATION,
200            "fine_level": C::PRECAT_COPY_FINE_LEVEL,
201        };
202
203        let mut copy = EgValue::create("acp", copy)?;
204
205        let pclib = self
206            .settings
207            .get_value("circ.pre_cat_copy_circ_lib")?
208            .clone();
209
210        if let Some(sn) = pclib.as_str() {
211            let o = org::by_shortname(self.editor(), sn)?;
212            copy["circ_lib"] = o["id"].clone();
213        }
214
215        let copy = self.editor().create(copy)?;
216
217        self.copy_id = copy.id()?;
218
219        // Reload a fleshed version of the copy we just created.
220        self.load_copy()?;
221
222        Ok(())
223    }
224
225    fn update_existing_precat(&mut self) -> EgResult<()> {
226        let copy = self.copy.as_ref().unwrap(); // known good.
227
228        log::info!("{self} modifying existing pre-cat copy {}", copy["id"]);
229
230        let dummy_title = self
231            .options
232            .get("dummy_title")
233            .map(|dt| dt.as_str())
234            .unwrap_or(copy["dummy_title"].as_str())
235            .unwrap_or("");
236
237        let dummy_author = self
238            .options
239            .get("dummy_author")
240            .map(|dt| dt.as_str())
241            .unwrap_or(copy["dummy_author"].as_str())
242            .unwrap_or("");
243
244        let dummy_isbn = self
245            .options
246            .get("dummy_isbn")
247            .map(|dt| dt.as_str())
248            .unwrap_or(copy["dummy_isbn"].as_str())
249            .unwrap_or("");
250
251        let circ_modifier = self
252            .options
253            .get("circ_modifier")
254            .map(|m| m.as_str())
255            .unwrap_or(copy["circ_modifier"].as_str());
256
257        self.update_copy(eg::hash! {
258            "editor": self.requestor_id()?,
259            "edit_date": "now",
260            "dummy_title": dummy_title,
261            "dummy_author": dummy_author,
262            "dummy_isbn": dummy_isbn,
263            "circ_modifier": circ_modifier,
264        })?;
265
266        Ok(())
267    }
268
269    fn set_item_deposit_events(&mut self) -> EgResult<()> {
270        if self.is_deposit() && !self.is_deposit_exempt()? {
271            let mut evt = EgEvent::new("ITEM_DEPOSIT_REQUIRED");
272            evt.set_payload(self.copy().clone());
273            self.add_event(evt)
274        }
275
276        if self.is_rental() && !self.is_rental_exempt()? {
277            let mut evt = EgEvent::new("ITEM_RENTAL_FEE_REQUIRED");
278            evt.set_payload(self.copy().clone());
279            self.add_event(evt)
280        }
281
282        Ok(())
283    }
284
285    fn check_captured_hold(&mut self) -> EgResult<()> {
286        if self.copy()["status"].id()? != C::COPY_STATUS_ON_HOLDS_SHELF {
287            return Ok(());
288        }
289
290        let query = eg::hash! {
291            "current_copy": self.copy_id,
292            "capture_time": {"!=": eg::NULL },
293            "cancel_time": eg::NULL,
294            "fulfillment_time": eg::NULL
295        };
296
297        let flesh = eg::hash! {
298            "limit": 1,
299            "flesh": 1,
300            "flesh_fields": {"ahr": ["usr"]}
301        };
302
303        let hold = match self.editor().search_with_ops("ahr", query, flesh)?.pop() {
304            Some(h) => h,
305            None => return Ok(()),
306        };
307
308        if hold["usr"].id()? == self.patron_id {
309            self.checkout_is_for_hold = Some(hold);
310            return Ok(());
311        }
312
313        log::info!("{self} item is on holds shelf for another patron");
314
315        // NOTE this is what the Perl does, but ideally patron display
316        // info is collected via the patron ID, not this bit of name logic.
317        let fname = hold["usr"]["first_given_name"].string()?;
318        let lname = hold["usr"]["family_name"].string()?;
319        let pid = hold["usr"]["id"].int()?;
320        let hid = hold["id"].int()?;
321
322        let payload = eg::hash! {
323            "patron_name": format!("{fname} {lname}"),
324            "patron_id": pid,
325            "hold_id": hid,
326        };
327
328        let mut evt = EgEvent::new("ITEM_ON_HOLDS_SHELF");
329        evt.set_payload(payload);
330        self.add_event(evt);
331
332        self.hold_found_for_alt_patron = Some(hold);
333
334        Ok(())
335    }
336
337    fn check_copy_status(&mut self) -> EgResult<()> {
338        if let Some(copy) = self.copy.as_ref() {
339            if let Some(id) = copy["status"]["id"].as_i64() {
340                if id == C::COPY_STATUS_IN_TRANSIT {
341                    self.exit_err_on_event_code("COPY_IN_TRANSIT")?;
342                }
343            }
344        }
345        Ok(())
346    }
347
348    /// If there is an open claims-returned circ on our copy and
349    /// we are in override mode, check in the circ.  Otherwise,
350    /// exit with an event.
351    fn handle_claims_returned(&mut self) -> EgResult<()> {
352        let query = eg::hash! {
353            "target_copy": self.copy_id,
354            "stop_fines": "CLAIMSRETURNED",
355            "checkin_time": eg::NULL,
356        };
357
358        let mut circ = match self.editor().search("circ", query)?.pop() {
359            Some(c) => c,
360            None => return Ok(()),
361        };
362
363        if !self.can_override_event("CIRC_CLAIMS_RETURNED") {
364            return self.exit_err_on_event_code("CIRC_CLAIMS_RETURNED");
365        }
366
367        circ["checkin_time"] = EgValue::from("now");
368        circ["checkin_scan_time"] = EgValue::from("now");
369        circ["checkin_lib"] = EgValue::from(self.circ_lib);
370        circ["checkin_staff"] = EgValue::from(self.requestor_id()?);
371
372        if let Some(id) = self.editor().requestor_ws_id() {
373            circ["checkin_workstation"] = EgValue::from(id);
374        }
375
376        self.editor().update(circ).map(|_| ())
377    }
378
379    fn check_for_open_circ(&mut self) -> EgResult<()> {
380        if self.is_renewal() {
381            return Ok(());
382        }
383
384        let query = eg::hash! {
385            "target_copy":  self.copy_id,
386            "checkin_time": eg::NULL,
387        };
388
389        let circ = match self.editor().search("circ", query)?.pop() {
390            Some(c) => c,
391            None => return Ok(()),
392        };
393
394        let mut payload = eg::hash! {"copy": self.copy().clone()};
395
396        if self.patron_id == circ["usr"].int()? {
397            payload["old_circ"] = circ.clone();
398
399            // If there is an open circulation on the checkout item and
400            // an auto-renew interval is defined, inform the caller
401            // that they should go ahead and renew the item instead of
402            // warning about open circulations.
403
404            if let Some(intvl) = self
405                .settings
406                .get_value("circ.checkout_auto_renew_age")?
407                .as_str()
408            {
409                let interval = date::interval_to_seconds(intvl)?;
410                let xact_start = date::parse_datetime(circ["xact_start"].as_str().unwrap())?;
411
412                let cutoff = xact_start + Duration::from_secs(interval as u64);
413
414                if date::now() > cutoff {
415                    payload["auto_renew"] = EgValue::from(1);
416                }
417            }
418        }
419
420        let mut evt = EgEvent::new("OPEN_CIRCULATION_EXISTS");
421        evt.set_payload(payload);
422
423        self.exit_err_on_event(evt)
424    }
425
426    /// Collect runtime circ policy data from the database.
427    ///
428    /// self.circ_policy_results will contain whatever the database resturns.
429    /// On success, self.circ_policy_rules will be populated.
430    fn set_circ_policy(&mut self) -> EgResult<()> {
431        let func = if self.is_renewal() {
432            "action.item_user_renew_test"
433        } else {
434            "action.item_user_circ_test"
435        };
436
437        // We check permit test results before verifying we have a copy,
438        // because we need the results for noncat/precat checkouts.
439        let copy_id = if self.copy.is_none()
440            || self.is_noncat
441            || (self.precat_requested() && !self.is_override && !self.is_renewal())
442        {
443            eg::NULL
444        } else {
445            EgValue::from(self.copy_id)
446        };
447
448        let query = eg::hash! {
449            "from": [
450                func,
451                self.circ_lib,
452                copy_id,
453                self.patron_id
454            ]
455        };
456
457        let results = self.editor().json_query(query)?;
458
459        log::debug!("{self} {func} returned: {:?}", results);
460
461        if results.is_empty() {
462            return self.exit_err_on_event_code("NO_POLICY_MATCHPOINT");
463        };
464
465        // Pull the policy data from the first one, which will be the
466        // success data if we have any.
467
468        let policy = &results[0];
469
470        self.circ_test_success = policy["success"].boolish();
471
472        if self.circ_test_success && policy["duration_rule"].is_null() {
473            // Successful lookup with no duration rule indicates
474            // unlimited item checkout.  Nothing left to lookup.
475            self.circ_policy_unlimited = true;
476            return Ok(());
477        }
478
479        if policy["matchpoint"].is_null() {
480            self.circ_policy_results = Some(results);
481            return Ok(());
482        }
483
484        // Delay generation of the err string if we don't need it.
485        let err = || format!("Incomplete circ policy: {}", policy);
486
487        let limit_groups = if policy["limit_groups"].is_array() {
488            Some(policy["limit_groups"].clone())
489        } else {
490            None
491        };
492
493        let mut duration_rule = self
494            .editor()
495            .retrieve("crcd", policy["duration_rule"].clone())?
496            .ok_or_else(err)?;
497
498        let mut recurring_fine_rule = self
499            .editor()
500            .retrieve("crrf", policy["recurring_fine_rule"].clone())?
501            .ok_or_else(err)?;
502
503        let max_fine_rule = self
504            .editor()
505            .retrieve("crmf", policy["max_fine_rule"].clone())?
506            .ok_or_else(err)?;
507
508        // optional
509        let hard_due_date = self
510            .editor()
511            .retrieve("chdd", policy["hard_due_date"].clone())?;
512
513        if let Ok(n) = policy["renewals"].int() {
514            duration_rule["max_renewals"] = EgValue::from(n);
515        }
516
517        if let Some(s) = policy["grace_period"].as_str() {
518            recurring_fine_rule["grace_period"] = EgValue::from(s);
519        }
520
521        let max_fine = self.calc_max_fine(&max_fine_rule)?;
522        let copy = self.copy();
523
524        let copy_duration = copy["loan_duration"].int()?;
525        let copy_fine_level = copy["fine_level"].int()?;
526
527        let duration = match copy_duration {
528            C::CIRC_DURATION_SHORT => duration_rule["shrt"].string()?,
529            C::CIRC_DURATION_EXTENDED => duration_rule["extended"].string()?,
530            _ => duration_rule["normal"].string()?,
531        };
532
533        let recurring_fine = match copy_fine_level {
534            C::CIRC_FINE_LEVEL_LOW => recurring_fine_rule["low"].float()?,
535            C::CIRC_FINE_LEVEL_HIGH => recurring_fine_rule["high"].float()?,
536            _ => recurring_fine_rule["normal"].float()?,
537        };
538
539        let matchpoint = policy["matchpoint"].clone();
540
541        let rules = CircPolicy {
542            matchpoint,
543            duration,
544            recurring_fine,
545            max_fine,
546            duration_rule,
547            recurring_fine_rule,
548            max_fine_rule,
549            hard_due_date,
550            limit_groups,
551        };
552
553        self.circ_policy_rules = Some(rules);
554        self.circ_policy_results = Some(results);
555
556        Ok(())
557    }
558
559    /// Check for patron or item policy blocks and override where possible.
560    fn inspect_policy_failures(&mut self) -> EgResult<()> {
561        if self.circ_test_success {
562            return Ok(());
563        }
564
565        let mut policy_results = match self.circ_policy_results.take() {
566            Some(p) => p,
567            None => Err("Non-success circ policy has no policy data".to_string())?,
568        };
569
570        if self.is_noncat || self.precat_requested() {
571            // "no_item" failures are OK for non-cat checkouts and
572            // when precat is requested.
573
574            policy_results.retain(|r| {
575                if let Some(fp) = r["fail_part"].as_str() {
576                    return fp != "no_item";
577                }
578                true
579            });
580        }
581
582        if self.checkout_is_for_hold.is_some() {
583            // If this checkout will fulfill a hold, ignore CIRC blocks
584            // and rely instead on the (later-checked) FULFILL blocks.
585
586            let penalty_codes: Vec<&str> = policy_results
587                .iter()
588                .filter(|r| r["fail_part"].is_string())
589                .map(|r| r.as_str().unwrap())
590                .collect();
591
592            let query = eg::hash! {
593                "name": penalty_codes,
594                "block_list": {"like": "%CIRC%"}
595            };
596
597            let block_pens = self.editor().search("csp", query)?;
598            let block_pen_names: Vec<&str> = block_pens
599                .iter()
600                .map(|p| p["name"].as_str().unwrap())
601                .collect();
602
603            let mut keepers = Vec::new();
604
605            for pr in policy_results.drain(..) {
606                let pr_name = pr["fail_part"].as_str().unwrap_or("");
607                if !block_pen_names.contains(&pr_name) {
608                    keepers.push(pr);
609                }
610            }
611
612            policy_results = keepers;
613        }
614
615        // Map fail_part values to legacy event codes and add the
616        // events to our working list.
617        for pr in policy_results.iter() {
618            let fail_part = match pr["fail_part"].as_str() {
619                Some(fp) => fp,
620                None => continue,
621            };
622
623            // Use the mapped value if we have one or default to
624            // using the fail_part as the event code.
625            let evt_code = LEGACY_CIRC_EVENT_MAP
626                .iter()
627                .filter(|(fp, _)| fp == &fail_part)
628                .map(|(_, code)| code)
629                .next()
630                .unwrap_or(&fail_part);
631
632            self.add_event_code(evt_code);
633        }
634
635        self.circ_policy_results = Some(policy_results);
636
637        Ok(())
638    }
639
640    fn calc_max_fine(&mut self, max_fine_rule: &EgValue) -> EgResult<f64> {
641        let rule_amount = max_fine_rule["amount"].float()?;
642
643        let copy_id = self.copy_id;
644
645        if max_fine_rule["is_percent"].boolish() {
646            let copy_price = billing::get_copy_price(self.editor(), copy_id)?;
647            return Ok((copy_price * rule_amount) / 100.0);
648        }
649
650        if self
651            .settings
652            .get_value("circ.max_fine.cap_at_price")?
653            .boolish()
654        {
655            let copy_price = billing::get_copy_price(self.editor(), copy_id)?;
656            let amount = if rule_amount > copy_price {
657                copy_price
658            } else {
659                rule_amount
660            };
661
662            return Ok(amount);
663        }
664
665        Ok(rule_amount)
666    }
667
668    fn build_checkout_circ(&mut self) -> EgResult<()> {
669        let mut circ = eg::hash! {
670            "target_copy": self.copy_id,
671            "usr": self.patron_id,
672            "circ_lib": self.circ_lib,
673            "circ_staff": self.requestor_id()?,
674        };
675
676        if let Some(ws) = self.editor().requestor_ws_id() {
677            circ["workstation"] = EgValue::from(ws);
678        };
679
680        if let Some(ct) = self.options.get("checkout_time") {
681            circ["xact_start"] = ct.clone();
682        }
683
684        if let Some(id) = self.parent_circ {
685            circ["parent_circ"] = EgValue::from(id);
686        }
687
688        if self.is_renewal() {
689            if self
690                .options
691                .get("opac_renewal")
692                .unwrap_or(&eg::NULL)
693                .boolish()
694            {
695                circ["opac_renewal"] = EgValue::from("t");
696            }
697            if self
698                .options
699                .get("phone_renewal")
700                .unwrap_or(&eg::NULL)
701                .boolish()
702            {
703                circ["phone_renewal"] = EgValue::from("t");
704            }
705            if self
706                .options
707                .get("desk_renewal")
708                .unwrap_or(&eg::NULL)
709                .boolish()
710            {
711                circ["desk_renewal"] = EgValue::from("t");
712            }
713            if self
714                .options
715                .get("auto_renewal")
716                .unwrap_or(&eg::NULL)
717                .boolish()
718            {
719                circ["auto_renewal"] = EgValue::from("t");
720            }
721
722            circ["renewal_remaining"] = EgValue::from(self.renewal_remaining);
723            circ["auto_renewal_remaining"] = EgValue::from(self.auto_renewal_remaining);
724        }
725
726        if self.circ_policy_unlimited {
727            circ["duration_rule"] = EgValue::from(C::CIRC_POLICY_UNLIMITED);
728            circ["recurring_fine_rule"] = EgValue::from(C::CIRC_POLICY_UNLIMITED);
729            circ["max_fine_rule"] = EgValue::from(C::CIRC_POLICY_UNLIMITED);
730            circ["renewal_remaining"] = EgValue::from(0);
731            circ["grace_period"] = EgValue::from(0);
732        } else if let Some(policy) = self.circ_policy_rules.as_ref() {
733            circ["duration"] = EgValue::from(policy.duration.to_string());
734            circ["duration_rule"] = policy.duration_rule["name"].clone();
735
736            circ["recurring_fine"] = EgValue::from(policy.recurring_fine);
737            circ["recurring_fine_rule"] = policy.recurring_fine_rule["name"].clone();
738            circ["fine_interval"] = policy.recurring_fine_rule["recurrence_interval"].clone();
739
740            circ["max_fine"] = EgValue::from(policy.max_fine);
741            circ["max_fine_rule"] = policy.max_fine_rule["name"].clone();
742
743            circ["renewal_remaining"] = policy.duration_rule["max_renewals"].clone();
744            circ["auto_renewal_remaining"] = policy.duration_rule["max_auto_renewals"].clone();
745
746            // may be null
747            circ["grace_period"] = policy.recurring_fine_rule["grace_period"].clone();
748        } else {
749            return Err("Cannot build circ without a policy".into());
750        }
751
752        // We don't create the circ in the DB yet.
753        self.circ = Some(circ);
754
755        Ok(())
756    }
757
758    fn apply_due_date(&mut self) -> EgResult<()> {
759        let is_manual = self.set_manual_due_date()?;
760
761        if !is_manual {
762            self.set_initial_due_date()?;
763        }
764
765        let shift_to_start = self.apply_booking_due_date(is_manual)?;
766
767        if !is_manual {
768            self.extend_due_date(shift_to_start)?;
769        }
770
771        Ok(())
772    }
773
774    /// Apply the user-provided due date.
775    fn set_manual_due_date(&mut self) -> EgResult<bool> {
776        let due_val = match self.options.get("due_date") {
777            Some(d) => d.clone(),
778            None => return Ok(false),
779        };
780
781        let circ_lib = self.circ_lib;
782
783        if !self
784            .editor()
785            .allowed_at("CIRC_OVERRIDE_DUE_DATE", circ_lib)?
786        {
787            return Err(self.editor().die_event());
788        }
789
790        self.circ.as_mut().unwrap()["due_date"] = due_val;
791
792        Ok(true)
793    }
794
795    /// Set the initial circ due date based on the circulation policy info.
796    fn set_initial_due_date(&mut self) -> EgResult<()> {
797        // A force / manual due date overrides any policy calculation.
798        let policy = match self.circ_policy_rules.as_ref() {
799            Some(p) => p,
800            None => return Ok(()),
801        };
802
803        let timezone = self
804            .settings
805            .get_value("lib.timezone")?
806            .as_str()
807            .unwrap_or("local");
808
809        let start_date = match self.circ.as_ref().unwrap()["xact_start"].as_str() {
810            Some(d) => date::parse_datetime(d)?,
811            None => date::now(),
812        };
813
814        let start_date = date::set_timezone(start_date, timezone)?;
815
816        let dur_secs = date::interval_to_seconds(&policy.duration)?;
817
818        let mut due_date = start_date + Duration::from_secs(dur_secs as u64);
819
820        if let Some(hdd) = policy.hard_due_date.as_ref() {
821            let cdate_str = hdd["ceiling_date"].as_str().unwrap();
822            let cdate = date::parse_datetime(cdate_str)?;
823            let force = hdd["forceto"].boolish();
824
825            if cdate > date::now() && (cdate < due_date || force) {
826                due_date = cdate;
827            }
828        }
829
830        self.circ.as_mut().unwrap()["due_date"] = EgValue::from(date::to_iso(&due_date));
831
832        Ok(())
833    }
834
835    /// Check for booking conflicts and shorten the due date if we need
836    /// to apply some elbow room.
837    fn apply_booking_due_date(&mut self, is_manual: bool) -> EgResult<bool> {
838        if !self.is_booking_enabled() {
839            return Ok(false);
840        }
841
842        let due_date = match self.circ.as_ref().unwrap()["due_date"].as_str() {
843            Some(s) => s.to_string(),
844            None => return Ok(false),
845        };
846
847        let query = eg::hash! {"barcode": self.copy()["barcode"].clone()};
848        let flesh = eg::hash! {"flesh": 1, "flesh_fields": {"brsrc": ["type"]}};
849
850        let resource = match self.editor().search_with_ops("brsrc", query, flesh)?.pop() {
851            Some(r) => r,
852            None => return Ok(false),
853        };
854
855        let stop_circ = self
856            .settings
857            .get_value("circ.booking_reservation.stop_circ")?
858            .boolish();
859
860        let query = eg::hash! {
861            "resource": resource["id"].clone(),
862            "search_start": "now",
863            "search_end": due_date.as_str(),
864            "fields": {
865                "cancel_time": eg::NULL,
866                "return_time": eg::NULL,
867            }
868        };
869
870        let booking_ids_op = self.editor().client_mut().send_recv_one(
871            "open-ils.booking",
872            "open-ils.booking.reservations.filtered_id_list",
873            query,
874        )?;
875
876        let booking_ids = match booking_ids_op {
877            Some(i) => i,
878            None => return Ok(false),
879        };
880
881        if !booking_ids.is_array() || booking_ids.is_empty() {
882            return Ok(false);
883        }
884
885        // See if any of the reservations overlap with our checkout
886        let due_date_dt = date::parse_datetime(&due_date)?;
887        let now_dt = date::now();
888        let mut bookings = Vec::new();
889
890        // First see if we need to block the circulation due to
891        // reservation overlap / stop-circ setting.
892        for id in booking_ids.members() {
893            let booking = self
894                .editor()
895                .retrieve("bresv", id.clone())?
896                .ok_or_else(|| self.editor().die_event())?;
897
898            let booking_start = date::parse_datetime(booking["start_time"].as_str().unwrap())?;
899
900            // Block the circ if a reservation is already active or
901            // we're told to prevent new circs on matching resources.
902            if booking_start < now_dt || stop_circ {
903                self.exit_err_on_event_code("COPY_RESERVED")?;
904            }
905
906            bookings.push(booking);
907        }
908
909        if is_manual {
910            // Manual due dates are not modified.  Note in the Perl
911            // code they appear to be modified, but are later set
912            // to the manual value, overwriting the booking logic
913            // for manual dates.  Guessing manaul due date are an
914            // outlier.
915            return Ok(false);
916        }
917
918        // See if we need to shorten the circ duration for this resource.
919        let shorten_by = match resource["type"]["elbow_room"].as_str() {
920            Some(s) => s,
921            None => match self
922                .settings
923                .get_value("circ.booking_reservation.default_elbow_room")?
924                .as_str()
925            {
926                Some(s) => s,
927                None => return Ok(false),
928            },
929        };
930
931        // We're configured to shorten the circ in the presence of
932        // reservations on this resource.
933        let interval = date::interval_to_seconds(shorten_by)?;
934        let due_date_dt = due_date_dt - Duration::from_secs(interval as u64);
935
936        if due_date_dt < now_dt {
937            self.exit_err_on_event_code("COPY_RESERVED")?;
938        }
939
940        // Apply the new due date and duration to our circ.
941        let mut duration = due_date_dt.timestamp() - now_dt.timestamp();
942        if duration % 86400 == 0 {
943            // Avoid precise day-granular durations because they
944            // result in bumping the due time to 23:59:59 via
945            // DB trigger, which we don't want here.
946            duration += 1;
947        }
948
949        let circ = self.circ.as_mut().unwrap();
950        circ["duration"] = EgValue::from(format!("{duration} seconds"));
951        circ["due_date"] = EgValue::from(date::to_iso(&due_date_dt));
952
953        // Changes were made.
954        Ok(true)
955    }
956
957    /// Extend the circ due date to avoid org unit closures.
958    fn extend_due_date(&mut self, _shift_to_start: bool) -> EgResult<()> {
959        if self.is_renewal() {
960            self.extend_renewal_due_date()?;
961        }
962
963        let due_date_str = match self.circ.as_ref().unwrap()["due_date"].as_str() {
964            Some(s) => s,
965            None => return Ok(()),
966        };
967
968        let due_date_dt = date::parse_datetime(due_date_str)?;
969
970        let circ_lib = self.circ_lib;
971        let org_open_data = org::next_open_date(self.editor(), circ_lib, &due_date_dt)?;
972
973        let due_date_dt = match org_open_data {
974            // No org unit closuers to consider.
975            org::OrgOpenState::Never | org::OrgOpenState::Open => return Ok(()),
976            org::OrgOpenState::OpensOnDate(d) => d,
977        };
978
979        // NOTE the Perl uses shift_to_start (for booking) to bump the
980        // due date to the beginning of the org unit closed period.
981        // However, if the org unit is closed now, that can result in
982        // an item being due now (or possibly in the past?).  There's a
983        // TODO in the code about the logic.  Fow now, set the due date
984        // to the first available time on or after the calculated due date.
985        log::info!("{self} bumping due date to avoid closures: {}", due_date_dt);
986
987        self.circ.as_mut().unwrap()["due_date"] = EgValue::from(date::to_iso(&due_date_dt));
988
989        Ok(())
990    }
991
992    /// Optionally extend the due date of a renewal if time was
993    /// lost on renewing early.
994    fn extend_renewal_due_date(&mut self) -> EgResult<()> {
995        let policy = match self.circ_policy_rules.as_ref() {
996            Some(p) => p,
997            None => return Ok(()),
998        };
999
1000        // Intervals can in theory be numeric; coerce to string result.
1001        let renew_extend_min_res = policy.matchpoint["renew_extend_min_interval"].string();
1002
1003        if !policy.matchpoint["renew_extends_due_date"].boolish() {
1004            // Not configured to extend on the matching policy.
1005            return Ok(());
1006        }
1007
1008        let due_date_str = match self.circ.as_ref().unwrap()["due_date"].as_str() {
1009            Some(d) => d,
1010            None => return Ok(()),
1011        };
1012
1013        let due_date = date::parse_datetime(due_date_str)?;
1014
1015        let parent_circ = self
1016            .parent_circ
1017            .ok_or_else(|| "Renewals require a parent circ".to_string())?;
1018
1019        let prev_circ = match self.editor().retrieve("circ", EgValue::from(parent_circ))? {
1020            Some(c) => c,
1021            None => return Err(self.editor().die_event()),
1022        };
1023
1024        let start_time_str = prev_circ["xact_start"].as_str().expect("required");
1025        let start_time = date::parse_datetime(start_time_str)?;
1026
1027        let prev_due_date_str = prev_circ["due_date"].as_str().expect("required");
1028        let prev_due_date = date::parse_datetime(prev_due_date_str)?;
1029
1030        let now_time = date::now();
1031
1032        if prev_due_date < now_time {
1033            // Renewed circ was overdue.  No extension to apply.
1034            return Ok(());
1035        }
1036
1037        // Make sure the renewal is not occurring too early in the
1038        // parent circ's lifecycle.
1039        if let Ok(intvl) = renew_extend_min_res {
1040            let min_duration = date::interval_to_seconds(&intvl)?;
1041            let co_duration = now_time - start_time;
1042
1043            if co_duration.num_seconds() < min_duration {
1044                // Renewal occurred too early in the cycle to result in an
1045                // extension of the due date on the renewal.
1046
1047                // If the new due date falls before the due date of
1048                // the previous circulation, though, use the due date of the
1049                // prev.  circ so the patron does not lose time.
1050                let due = if due_date < prev_due_date {
1051                    prev_due_date
1052                } else {
1053                    due_date
1054                };
1055
1056                self.circ.as_mut().unwrap()["due_date"] = EgValue::from(date::to_iso(&due));
1057
1058                return Ok(());
1059            }
1060        }
1061
1062        // Item was checked out long enough during the previous circulation
1063        // to consider extending the due date of the renewal to cover the gap.
1064
1065        // Amount of the previous duration that was left unused.
1066        let remaining_duration = prev_due_date - now_time;
1067
1068        let due_date = due_date + remaining_duration;
1069
1070        // If the calculated due date falls before the due date of the previous
1071        // circulation, use the due date of the prev. circ so the patron does
1072        // not lose time.
1073        let due = if due_date < prev_due_date {
1074            prev_due_date
1075        } else {
1076            due_date
1077        };
1078
1079        log::info!(
1080            "{self} renewal due date extension landed on due date: {}",
1081            due
1082        );
1083
1084        self.circ.as_mut().unwrap()["due_date"] = EgValue::from(date::to_iso(&due));
1085
1086        Ok(())
1087    }
1088
1089    fn save_checkout_circ(&mut self) -> EgResult<()> {
1090        // At this point we know we have a circ.
1091        // Turn our circ hash into an IDL-classed object.
1092        let circ = self.circ.as_ref().unwrap().clone();
1093        let clone = EgValue::create("circ", circ)?;
1094
1095        log::debug!("{self} creating circulation {}", clone.dump());
1096
1097        // Put it in the DB
1098        self.circ = Some(self.editor().create(clone)?);
1099
1100        // We did it. We checked out a copy.
1101        self.update_copy(eg::hash! {"status": C::COPY_STATUS_CHECKED_OUT})?;
1102
1103        Ok(())
1104    }
1105
1106    fn apply_limit_groups(&mut self) -> EgResult<()> {
1107        let limit_groups = match self.circ_policy_rules.as_ref() {
1108            Some(p) => match p.limit_groups.as_ref() {
1109                Some(g) => g,
1110                None => return Ok(()),
1111            },
1112            None => return Ok(()),
1113        };
1114
1115        let query = eg::hash! {
1116            "from": [
1117                "action.link_circ_limit_groups",
1118                self.circ.as_ref().unwrap()["id"].clone(),
1119                limit_groups.clone()
1120            ]
1121        };
1122
1123        self.editor().json_query(query)?;
1124
1125        Ok(())
1126    }
1127
1128    fn is_deposit(&self) -> bool {
1129        if let Some(copy) = self.copy.as_ref() {
1130            if let Some(amount) = copy["deposit_amount"].as_f64() {
1131                return amount > 0.0 && copy["deposit"].boolish();
1132            }
1133        }
1134        false
1135    }
1136
1137    // True if we have a deposit_amount but the desposit flag is false.
1138    fn is_rental(&self) -> bool {
1139        if let Some(copy) = self.copy.as_ref() {
1140            if let Some(amount) = copy["deposit_amount"].as_f64() {
1141                return amount > 0.0 && !copy["deposit"].boolish();
1142            }
1143        }
1144        false
1145    }
1146
1147    fn apply_deposit_fee(&mut self) -> EgResult<()> {
1148        let is_deposit = self.is_deposit();
1149        let is_rental = self.is_rental();
1150
1151        if !is_deposit && !is_rental {
1152            return Ok(());
1153        }
1154
1155        // confirmed above
1156        let deposit_amount = self.copy()["deposit_amount"].as_f64().unwrap();
1157
1158        let skip_deposit_fee = self.settings.get_value("skip_deposit_fee")?.boolish();
1159        if is_deposit && (skip_deposit_fee || self.is_deposit_exempt()?) {
1160            return Ok(());
1161        }
1162
1163        let skip_rental_fee = self.settings.get_value("skip_rental_fee")?.boolish();
1164        if is_rental && (skip_rental_fee | self.is_rental_exempt()?) {
1165            return Ok(());
1166        }
1167
1168        let mut btype = C::BTYPE_DEPOSIT;
1169        let mut btype_label = C::BTYPE_LABEL_DEPOSIT;
1170
1171        if is_rental {
1172            btype = C::BTYPE_RENTAL;
1173            btype_label = C::BTYPE_LABEL_RENTAL;
1174        }
1175
1176        let circ_id = self.circ.as_ref().expect("Circ is Set").id()?;
1177
1178        let bill = billing::create_bill(
1179            self.editor(),
1180            deposit_amount,
1181            billing::BillingType {
1182                id: btype,
1183                label: btype_label.to_string(),
1184            },
1185            circ_id,
1186            Some(C::BTYPE_NOTE_SYSTEM),
1187            None,
1188            None,
1189        )?;
1190
1191        if is_deposit {
1192            self.deposit_billing = Some(bill);
1193        } else {
1194            self.rental_billing = Some(bill);
1195        }
1196
1197        Ok(())
1198    }
1199
1200    fn is_deposit_exempt(&mut self) -> EgResult<bool> {
1201        let profile = self.patron.as_ref().unwrap()["profile"].id()?;
1202
1203        let groups = self.settings.get_value("circ.deposit.exempt_groups")?;
1204
1205        if !groups.is_array() || groups.is_empty() {
1206            return Ok(false);
1207        }
1208
1209        let mut parent_ids = Vec::new();
1210        for grp in groups.members() {
1211            parent_ids.push(grp.id()?);
1212        }
1213
1214        self.is_group_descendant(profile, parent_ids.as_slice())
1215    }
1216
1217    fn is_rental_exempt(&mut self) -> EgResult<bool> {
1218        let profile = self.patron.as_ref().unwrap()["profile"].id()?;
1219
1220        let groups = self.settings.get_value("circ.rental.exempt_groups")?;
1221
1222        if !groups.is_array() || groups.is_empty() {
1223            return Ok(false);
1224        }
1225
1226        let mut parent_ids = Vec::new();
1227        for grp in groups.members() {
1228            parent_ids.push(grp.id()?);
1229        }
1230
1231        self.is_group_descendant(profile, parent_ids.as_slice())
1232    }
1233
1234    /// Returns true if the child is a descendant of any of the parent
1235    /// profile group IDs
1236    fn is_group_descendant(&mut self, child_id: i64, parent_ids: &[i64]) -> EgResult<bool> {
1237        let query = eg::hash! {"from": ["permission.grp_ancestors", child_id] };
1238        let ancestors = self.editor().json_query(query)?;
1239        for parent_id in parent_ids {
1240            for grp in &ancestors {
1241                if grp.id()? == *parent_id {
1242                    return Ok(true);
1243                }
1244            }
1245        }
1246
1247        Ok(false)
1248    }
1249
1250    /// See if we can fulfill a hold for this patron with this
1251    /// checked out item.
1252    fn handle_checkout_holds(&mut self) -> EgResult<()> {
1253        if self.is_noncat {
1254            return Ok(());
1255        }
1256
1257        // checkout_is_for_hold will contain the hold we already know
1258        // to be on the holds shelf for our patron + item.
1259        let mut maybe_hold = self.checkout_is_for_hold.take();
1260
1261        if maybe_hold.is_none() {
1262            maybe_hold = self.handle_targeted_hold()?;
1263        }
1264
1265        if maybe_hold.is_none() {
1266            maybe_hold = self.find_related_user_hold()?;
1267        }
1268
1269        let mut hold = match maybe_hold.take() {
1270            Some(h) => h,
1271            None => return Ok(()),
1272        };
1273
1274        self.check_hold_fulfill_blocks()?;
1275
1276        let hold_id = hold.id()?;
1277
1278        log::info!("{self} fulfilling hold {hold_id}");
1279
1280        hold["hopeless_date"].take();
1281        hold["current_copy"] = EgValue::from(self.copy_id);
1282        hold["fulfillment_time"] = EgValue::from("now");
1283        hold["fulfillment_staff"] = EgValue::from(self.requestor_id()?);
1284        hold["fulfillment_lib"] = EgValue::from(self.circ_lib);
1285
1286        if hold["capture_time"].is_null() {
1287            hold["capture_time"] = EgValue::from("now");
1288        }
1289
1290        self.editor().create(hold)?;
1291
1292        self.fulfilled_hold_ids = Some(vec![hold_id]);
1293
1294        Ok(())
1295    }
1296
1297    /// If we have a hold that targets another patron -- we have already
1298    /// overridden that event -- then reset the hold so it can go on
1299    /// to target a different copy.
1300    fn handle_targeted_hold(&mut self) -> EgResult<Option<EgValue>> {
1301        let mut hold = match self.hold_found_for_alt_patron.take() {
1302            Some(h) => h,
1303            None => return Ok(None),
1304        };
1305
1306        hold["clear_prev_check_time"].take();
1307        hold["clear_current_copy"].take();
1308        hold["clear_capture_time"].take();
1309        hold["clear_shelf_time"].take();
1310        hold["clear_shelf_expire_time"].take();
1311        hold["clear_current_shelf_lib"].take();
1312
1313        log::info!(
1314            "{self} un-targeting hold {} because our copy is checking out",
1315            hold["id"],
1316        );
1317
1318        self.editor().update(hold).map(|_| None)
1319    }
1320
1321    /// Find a similar hold to fulfill.
1322    ///
1323    /// If the circ.checkout_fill_related_hold setting is turned on
1324    /// and no hold for the patron directly targets the checked out
1325    /// item, see if there is another hold for the patron that could be
1326    /// fulfilled by the checked out item.  Fulfill the oldest hold and
1327    /// only fulfill 1 of them.
1328    ///
1329    /// First, check for one that the copy matches via hold_copy_map,
1330    /// ensuring that *any* hold type that this copy could fill may end
1331    /// up filled.
1332    ///
1333    /// Then, if circ.checkout_fill_related_hold_exact_match_only is not
1334    /// enabled, look for a Title (T) or Volume (V) hold that matches
1335    /// the item. This allows items that are non-requestable to count as
1336    /// capturing those hold types.
1337    /// ------------------------------------------------------------------------------
1338    fn find_related_user_hold(&mut self) -> EgResult<Option<EgValue>> {
1339        if self.is_precat_copy() {
1340            return Ok(None);
1341        }
1342
1343        if !self
1344            .settings
1345            .get_value("circ.checkout_fills_related_hold")?
1346            .boolish()
1347        {
1348            return Ok(None);
1349        }
1350
1351        let copy_id = self.copy_id;
1352        let patron_id = self.patron_id;
1353
1354        // find the oldest unfulfilled hold that has not yet hit the holds shelf.
1355        let query = eg::hash! {
1356            "select": {"ahr": ["id"]},
1357            "from": {
1358                "ahr": {
1359                    "ahcm": {
1360                        "field": "hold",
1361                        "fkey": "id"
1362                    },
1363                    "acp": {
1364                        "field": "id",
1365                        "fkey": "current_copy",
1366                        "type": "left" // there may be no current_copy
1367                    }
1368                }
1369            },
1370            "where": {
1371                "+ahr": {
1372                    "usr": patron_id,
1373                    "fulfillment_time": eg::NULL,
1374                    "cancel_time": eg::NULL,
1375                   "-or": [
1376                        {"expire_time": eg::NULL},
1377                        {"expire_time": {">": "now"}}
1378                    ]
1379                },
1380                "+ahcm": {
1381                    "target_copy": copy_id,
1382                },
1383                "+acp": {
1384                    "-or": [
1385                        {"id": eg::NULL}, // left-join copy may be nonexistent
1386                        {"status": {"!=": C::COPY_STATUS_ON_HOLDS_SHELF}},
1387                    ]
1388                }
1389            },
1390            "order_by": {"ahr": {"request_time": {"direction": "asc"}}},
1391            "limit": 1
1392        };
1393
1394        if let Some(hold) = self.editor().json_query(query)?.pop() {
1395            return self.editor().retrieve("ahr", hold["id"].clone());
1396        }
1397
1398        if self
1399            .settings
1400            .get_value("circ.checkout_fills_related_hold_exact_match_only")?
1401            .boolish()
1402        {
1403            // We only want exact matches and didn't find any.  We're done.
1404            return Ok(None);
1405        }
1406
1407        // Expand our search to more hold types that could be filled
1408        // by our checked out copy.
1409
1410        let circ_lib = self.circ_lib;
1411        let patron_id = self.patron_id;
1412        let copy_id = self.copy_id;
1413
1414        let hold_data = holds::related_to_copy(
1415            self.editor(),
1416            copy_id,
1417            Some(circ_lib),
1418            None, // frozen
1419            Some(patron_id),
1420            Some(false), // already on holds shelf
1421        )?;
1422
1423        if hold_data.is_empty() {
1424            return Ok(None);
1425        }
1426
1427        // holds::related_to_copy may return holds that patron does not
1428        // want filled by this copy, e.g. holds that target different
1429        // volumes or records.  Apply some additional filtering.
1430
1431        let record_id = self.copy()["call_number"]["record"].int()?;
1432        let volume_id = self.copy()["call_number"].id()?;
1433
1434        for hold in hold_data.iter() {
1435            let target = hold.target();
1436
1437            // The Perl only supports T and V holds.  Matching that for now.
1438
1439            if hold.hold_type() == holds::HoldType::Title && target == record_id {
1440                return self.editor().retrieve("ahr", hold.id());
1441            }
1442
1443            if hold.hold_type() == holds::HoldType::Volume && target == volume_id {
1444                return self.editor().retrieve("ahr", hold.id());
1445            }
1446        }
1447
1448        Ok(None)
1449    }
1450
1451    /// Exits with error if hold blocks are present and we are not
1452    /// overriding them.
1453    fn check_hold_fulfill_blocks(&mut self) -> EgResult<()> {
1454        let home_ou = self.patron.as_ref().unwrap()["home_ou"].int()?;
1455        let copy_ou = self.copy()["circ_lib"].int()?;
1456
1457        let circ_lib = self.circ_lib;
1458        let ou_prox = org::proximity(self.editor(), home_ou, circ_lib)?.unwrap_or(-1);
1459
1460        let copy_prox = if copy_ou == circ_lib {
1461            ou_prox
1462        } else {
1463            org::proximity(self.editor(), copy_ou, circ_lib)?.unwrap_or(-1)
1464        };
1465
1466        let query = eg::hash! {
1467            "select": {"csp": ["name", "label"]},
1468            "from": {"ausp": "csp"},
1469            "where": {
1470                "+ausp": {
1471                    "usr": self.patron_id,
1472                    "org_unit": org::full_path(self.editor(), circ_lib, None)?,
1473                    "-or": [
1474                        {"stop_date": eg::NULL},
1475                        {"stop_date": {">": "now"}}
1476                    ]
1477                },
1478                "+csp": {
1479                    "block_list": {"like": "%FULFILL%"},
1480                    "-or": [
1481                        {"ignore_proximity": eg::NULL},
1482                        {"ignore_proximity": {"<": ou_prox}},
1483                        {"ignore_proximity": {"<": copy_prox}}
1484                    ]
1485                }
1486            }
1487        };
1488
1489        let penalties = self.editor().json_query(query)?;
1490        for pen in penalties {
1491            let mut evt = EgEvent::new(pen["name"].as_str().unwrap());
1492            if let Some(d) = pen["label"].as_str() {
1493                evt.set_desc(d);
1494            }
1495            self.add_event(evt);
1496        }
1497
1498        self.try_override_events()
1499    }
1500
1501    fn build_checkout_response(&mut self) -> EgResult<()> {
1502        let mut record = None;
1503        if !self.is_precat_copy() {
1504            let record_id = self.copy()["call_number"]["record"].int()?;
1505            record = Some(bib::map_to_mvr(self.editor(), record_id)?);
1506        }
1507
1508        let mut copy = self.copy().clone();
1509        let volume = copy["call_number"].take();
1510        copy.deflesh()?;
1511
1512        let circ = self.circ.as_ref().unwrap().clone();
1513        let patron = self.patron.as_ref().unwrap().clone();
1514        let patron_id = self.patron_id;
1515
1516        let patron_money = self.editor().retrieve("mus", patron_id)?;
1517
1518        let mut payload = eg::hash! {
1519            "copy": copy,
1520            "volume": volume,
1521            "record": record,
1522            "circ": circ,
1523            "patron": patron,
1524            "patron_money": patron_money,
1525        };
1526
1527        if let Some(list) = self.fulfilled_hold_ids.as_ref() {
1528            payload["holds_fulfilled"] = EgValue::from(list.clone());
1529        }
1530
1531        if let Some(bill) = self.deposit_billing.as_ref() {
1532            payload["deposit_billing"] = bill.clone();
1533        }
1534
1535        if let Some(bill) = self.rental_billing.as_ref() {
1536            payload["rental_billing"] = bill.clone();
1537        }
1538
1539        // Flesh the billing summary for our checked-in circ.
1540        if let Some(pcirc) = self.parent_circ {
1541            let flesh = eg::hash! {
1542                "flesh": 1,
1543                "flesh_fields": {
1544                    "circ": ["billable_transaction"],
1545                    "mbt": ["summary"],
1546                }
1547            };
1548
1549            if let Some(circ) = self.editor().retrieve_with_ops("circ", pcirc, flesh)? {
1550                payload["parent_circ"] = circ;
1551            }
1552        }
1553
1554        let mut evt = EgEvent::success();
1555        evt.set_payload(payload);
1556        self.add_event(evt);
1557
1558        Ok(())
1559    }
1560}