import base64 import logging import uuid from datetime import timedelta from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) class SaleOrder(models.Model): _inherit = 'sale.order' # ----------------------------------------------------------------- # Renewal fields (v1) # ----------------------------------------------------------------- rental_auto_renew = fields.Boolean( string="Auto-Renew", default=True, tracking=True, help="Automatically renew this rental when the return date is reached.", ) rental_auto_renew_off_reason = fields.Text( string="Auto-Renew Disabled Reason", tracking=True, help="Reason for disabling automatic renewal on this order.", ) rental_renewal_count = fields.Integer( string="Renewal Count", default=0, readonly=True, copy=False, ) rental_max_renewals = fields.Integer( string="Max Renewals", default=0, help="Maximum number of automatic renewals. 0 = unlimited.", ) rental_next_renewal_date = fields.Date( string="Next Renewal Date", compute='_compute_rental_next_renewal_date', store=True, ) rental_reminder_sent = fields.Boolean( string="Reminder Sent", default=False, copy=False, ) rental_payment_token_id = fields.Many2one( 'payment.token', string="Payment Card on File", domain="[('partner_id', '=', partner_id)]", copy=False, help="Stored card used for automatic renewal payment collection.", ) rental_original_duration = fields.Integer( string="Original Duration (Days)", compute='_compute_rental_original_duration', store=True, help="Original rental duration in days, used for renewal period calculation.", ) rental_renewal_log_ids = fields.One2many( 'rental.renewal.log', 'order_id', string="Renewal History", ) rental_cancellation_request_ids = fields.One2many( 'rental.cancellation.request', 'order_id', string="Cancellation Requests", ) # ----------------------------------------------------------------- # Agreement fields (v2) # ----------------------------------------------------------------- rental_agreement_signed = fields.Boolean( string="Agreement Signed", default=False, copy=False, tracking=True, ) rental_agreement_signature = fields.Binary( string="Signature", copy=False, attachment=True, ) rental_agreement_signer_name = fields.Char( string="Signer Name", copy=False, ) rental_agreement_signed_date = fields.Datetime( string="Signed Date", copy=False, ) rental_agreement_token = fields.Char( string="Agreement Token", copy=False, index=True, ) rental_billing_address = fields.Char( string="Billing Address", copy=False, help="Billing address provided during agreement signing.", ) rental_billing_postal_code = fields.Char( string="Billing Postal Code", copy=False, help="Billing postal/zip code for card verification (AVS).", ) rental_agreement_document = fields.Binary( string="Rental Agreement PDF", copy=False, attachment=True, ) rental_agreement_document_filename = fields.Char( string="Agreement Filename", copy=False, ) # ----------------------------------------------------------------- # Security deposit fields (v2) # ----------------------------------------------------------------- rental_charges_invoice_id = fields.Many2one( 'account.move', string="Rental Charges Invoice", copy=False, ondelete='set null', help="Invoice for rental charges, delivery, and other services (excludes deposit).", ) rental_deposit_invoice_id = fields.Many2one( 'account.move', string="Deposit Invoice", copy=False, ondelete='set null', ) rental_deposit_status = fields.Selection( [ ('pending', 'Pending'), ('collected', 'Collected'), ('refund_hold', 'Refund Hold'), ('refunded', 'Refunded'), ('deducted', 'Deducted'), ], string="Deposit Status", copy=False, tracking=True, ) rental_deposit_refund_date = fields.Date( string="Deposit Refund Date", copy=False, help="Date when the security deposit refund will be processed.", ) # ----------------------------------------------------------------- # Inspection fields (v2) # ----------------------------------------------------------------- rental_inspection_status = fields.Selection( [ ('pending', 'Pending'), ('passed', 'Passed'), ('flagged', 'Flagged for Review'), ], string="Inspection Status", copy=False, ) rental_inspection_notes = fields.Text( string="Inspection Notes", copy=False, ) rental_inspection_photo_ids = fields.Many2many( 'ir.attachment', 'rental_inspection_photo_rel', 'order_id', 'attachment_id', string="Inspection Photos", copy=False, ) # ----------------------------------------------------------------- # Marketing / purchase conversion fields (v2) # ----------------------------------------------------------------- rental_marketing_email_sent = fields.Boolean( string="Marketing Email Sent", default=False, copy=False, ) rental_purchase_interest = fields.Boolean( string="Purchase Interest", default=False, copy=False, tracking=True, help="Customer expressed interest in purchasing the rental product.", ) rental_purchase_coupon_id = fields.Many2one( 'loyalty.card', string="Purchase Coupon", copy=False, ondelete='set null', ) # ----------------------------------------------------------------- # Close fields (v2) # ----------------------------------------------------------------- rental_closed = fields.Boolean( string="Transaction Closed", default=False, copy=False, tracking=True, ) # ----------------------------------------------------------------- # Smart-button count fields # ----------------------------------------------------------------- rental_deposit_invoice_count = fields.Integer( compute='_compute_rental_invoice_counts', ) rental_charges_invoice_count = fields.Integer( compute='_compute_rental_invoice_counts', ) rental_renewal_invoice_count = fields.Integer( compute='_compute_rental_invoice_counts', ) rental_refund_invoice_count = fields.Integer( compute='_compute_rental_invoice_counts', ) @api.depends('rental_return_date', 'rental_auto_renew') def _compute_rental_next_renewal_date(self): for order in self: if order.is_rental_order and order.rental_auto_renew and order.rental_return_date: order.rental_next_renewal_date = order.rental_return_date.date() else: order.rental_next_renewal_date = False @api.depends('rental_start_date', 'rental_return_date') def _compute_rental_original_duration(self): for order in self: if order.rental_start_date and order.rental_return_date: delta = order.rental_return_date - order.rental_start_date order.rental_original_duration = max(delta.days, 1) else: order.rental_original_duration = 0 @api.depends( 'rental_deposit_invoice_id', 'rental_charges_invoice_id', 'rental_renewal_log_ids.invoice_id', 'invoice_ids', ) def _compute_rental_invoice_counts(self): for order in self: order.rental_deposit_invoice_count = 1 if order.rental_deposit_invoice_id else 0 order.rental_charges_invoice_count = 1 if order.rental_charges_invoice_id else 0 order.rental_renewal_invoice_count = len( order.rental_renewal_log_ids.filtered(lambda r: r.invoice_id).mapped('invoice_id') ) deposit_inv_id = order.rental_deposit_invoice_id.id if order.rental_deposit_invoice_id else 0 order.rental_refund_invoice_count = len( order.invoice_ids.filtered( lambda inv: inv.move_type == 'out_refund' and inv.reversed_entry_id.id == deposit_inv_id ) ) if deposit_inv_id else 0 # ----------------------------------------------------------------- # Smart-button actions # ----------------------------------------------------------------- def action_view_deposit_invoice(self): self.ensure_one() if not self.rental_deposit_invoice_id: return return { 'type': 'ir.actions.act_window', 'name': _("Security Deposit"), 'res_model': 'account.move', 'res_id': self.rental_deposit_invoice_id.id, 'view_mode': 'form', 'target': 'current', } def action_view_rental_charges_invoice(self): self.ensure_one() if not self.rental_charges_invoice_id: return return { 'type': 'ir.actions.act_window', 'name': _("Rental Charges"), 'res_model': 'account.move', 'res_id': self.rental_charges_invoice_id.id, 'view_mode': 'form', 'target': 'current', } def action_view_renewal_invoices(self): self.ensure_one() invoices = self.rental_renewal_log_ids.filtered( lambda r: r.invoice_id ).mapped('invoice_id') if not invoices: return if len(invoices) == 1: return { 'type': 'ir.actions.act_window', 'name': _("Renewal Invoice"), 'res_model': 'account.move', 'res_id': invoices.id, 'view_mode': 'form', 'target': 'current', } return { 'type': 'ir.actions.act_window', 'name': _("Renewal Invoices"), 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [('id', 'in', invoices.ids)], 'target': 'current', } def action_view_refund_invoices(self): self.ensure_one() if not self.rental_deposit_invoice_id: return refunds = self.invoice_ids.filtered( lambda inv: inv.move_type == 'out_refund' and inv.reversed_entry_id == self.rental_deposit_invoice_id ) if not refunds: return if len(refunds) == 1: return { 'type': 'ir.actions.act_window', 'name': _("Refund"), 'res_model': 'account.move', 'res_id': refunds.id, 'view_mode': 'form', 'target': 'current', } return { 'type': 'ir.actions.act_window', 'name': _("Refund Invoices"), 'res_model': 'account.move', 'view_mode': 'list,form', 'domain': [('id', 'in', refunds.ids)], 'target': 'current', } # ----------------------------------------------------------------- # Return override -- intercept with inspection wizard # ----------------------------------------------------------------- def action_open_return(self): """Override to show the inspection wizard before processing returns. If inspection has already been completed for this order (e.g. via a technician task), fall through to the standard return flow. """ self.ensure_one() if self.rental_inspection_status: return super().action_open_return() return { 'name': _("Return & Inspection"), 'type': 'ir.actions.act_window', 'res_model': 'rental.return.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_order_id': self.id, }, } def _has_pending_cancellation(self): """Check if this order has an unresolved cancellation request.""" self.ensure_one() return self.rental_cancellation_request_ids.filtered( lambda r: r.state in ('new', 'confirmed') ) def _get_rental_duration_days(self): """Return the rental duration to use for renewal. Uses the stored original duration so renewals keep the same period length even if dates were manually adjusted. """ self.ensure_one() if self.rental_original_duration: return self.rental_original_duration if self.rental_start_date and self.rental_return_date: return max((self.rental_return_date - self.rental_start_date).days, 1) return 30 def _get_marketing_target_date(self): """When to send the purchase marketing email (percentage of period after start).""" self.ensure_one() pct = int(self.env['ir.config_parameter'].sudo().get_param( 'fusion_rental.marketing_email_pct', '23')) duration = self._get_rental_duration_days() offset_days = max(round(duration * pct / 100), 1) start = self.rental_start_date if not start: return False return (start + timedelta(days=offset_days)).date() def _get_reminder_target_date(self): """When to send the renewal reminder (percentage of period before renewal).""" self.ensure_one() pct = int(self.env['ir.config_parameter'].sudo().get_param( 'fusion_rental.renewal_reminder_pct', '10')) duration = self._get_rental_duration_days() offset_days = max(round(duration * pct / 100), 1) if not self.rental_next_renewal_date: return False return self.rental_next_renewal_date - timedelta(days=offset_days) def _is_short_term_rental(self): """True if rental duration is below the short-term threshold.""" self.ensure_one() threshold = int(self.env['ir.config_parameter'].sudo().get_param( 'fusion_rental.short_term_threshold_days', '3')) return self._get_rental_duration_days() < threshold def _short_term_grace_expired(self): """For short-term rentals, True if the return time + grace has passed.""" self.ensure_one() grace_hours = int(self.env['ir.config_parameter'].sudo().get_param( 'fusion_rental.short_term_grace_hours', '1')) if not self.rental_return_date: return False grace_deadline = self.rental_return_date + timedelta(hours=grace_hours) return fields.Datetime.now() >= grace_deadline def _get_rental_only_lines(self): """Return order lines that should be invoiced on renewal. Excludes security deposits, delivery/installation, and any other one-time charges. Only lines flagged as rental by Odoo core (is_rental=True) are included. """ self.ensure_one() return self.order_line.filtered( lambda l: l.is_rental and not l.is_security_deposit ) def _get_renewal_amount(self): """Compute the total renewal charge (rental lines only, tax-inclusive).""" self.ensure_one() rental_lines = self._get_rental_only_lines() return sum(line.price_total for line in rental_lines) def _create_renewal_invoice(self): """Create an invoice containing only the recurring rental lines.""" self.ensure_one() rental_lines = self._get_rental_only_lines() if not rental_lines: return self.env['account.move'] invoice_vals = self._prepare_invoice() invoice_line_vals = [] for line in rental_lines: inv_line = line._prepare_invoice_line() inv_line['quantity'] = line.product_uom_qty invoice_line_vals.append((0, 0, inv_line)) invoice_vals['invoice_line_ids'] = invoice_line_vals return self.env['account.move'].sudo().create(invoice_vals) def _prepare_renewal_cancellation_token(self): """Create a cancellation request with a unique token for the reminder email.""" self.ensure_one() token = uuid.uuid4().hex self.env['rental.cancellation.request'].create({ 'order_id': self.id, 'token': token, 'state': 'new', }) return token def _process_auto_renewal(self): """Execute auto-renewal for a single rental order. Extends the rental period, creates an invoice, attempts payment, and logs the renewal event. """ self.ensure_one() duration = self._get_rental_duration_days() old_start = self.rental_start_date old_return = self.rental_return_date new_start = old_return new_return = new_start + timedelta(days=duration) self.write({ 'rental_start_date': new_start, 'rental_return_date': new_return, }) self._recompute_rental_prices() invoice = self._create_renewal_invoice() if invoice: invoice.action_post() renewal_log = self.env['rental.renewal.log'].create({ 'order_id': self.id, 'renewal_number': self.rental_renewal_count + 1, 'previous_start_date': old_start, 'previous_return_date': old_return, 'new_start_date': new_start, 'new_return_date': new_return, 'invoice_id': invoice.id if invoice else False, 'renewal_type': 'automatic', 'state': 'draft', }) payment_ok = False if invoice and self.rental_payment_token_id: payment_ok = self._collect_renewal_payment(invoice, renewal_log) if payment_ok: self._send_invoice_with_receipt(invoice, 'renewal') if invoice and not self.rental_payment_token_id: self._notify_staff_manual_payment(invoice) renewal_log.write({'payment_status': 'pending'}) self.write({ 'rental_renewal_count': self.rental_renewal_count + 1, 'rental_reminder_sent': False, }) renewal_log.write({'state': 'done'}) self._send_renewal_confirmation_email(renewal_log, payment_ok) _logger.info( "Auto-renewed rental %s (renewal #%d), new period %s to %s", self.name, self.rental_renewal_count, new_start, new_return, ) def _collect_renewal_payment(self, invoice, renewal_log): """Attempt to charge the stored payment token for a renewal invoice. :param invoice: The posted account.move to collect payment for. :param renewal_log: The rental.renewal.log record to update. :return: True if payment succeeded, False otherwise. """ self.ensure_one() try: provider = self.rental_payment_token_id.provider_id payment_method = self.env['payment.method'].search( [('code', '=', 'card')], limit=1, ) if not payment_method: payment_method = self.env['payment.method'].search( [('code', 'in', ('visa', 'mastercard'))], limit=1, ) if not payment_method: _logger.error("No card payment method configured for rental auto-payment.") renewal_log.write({ 'payment_status': 'failed', 'notes': "No card payment method configured.", }) return False tx = self.env['payment.transaction'].sudo().create({ 'provider_id': provider.id, 'payment_method_id': payment_method.id, 'amount': invoice.amount_residual, 'currency_id': invoice.currency_id.id, 'partner_id': self.partner_id.id, 'operation': 'offline', 'token_id': self.rental_payment_token_id.id, 'invoice_ids': [(4, invoice.id)], }) tx._poynt_process_token_payment() if tx.state == 'done': renewal_log.write({ 'payment_status': 'paid', 'payment_transaction_id': tx.id, }) return True renewal_log.write({ 'payment_status': 'failed', 'payment_transaction_id': tx.id, 'notes': f"Payment transaction state: {tx.state}", }) self._notify_staff_manual_payment(invoice) return False except (UserError, ValidationError) as e: _logger.error("Auto-payment failed for rental %s: %s", self.name, e) renewal_log.write({ 'payment_status': 'failed', 'notes': str(e), }) self._notify_staff_manual_payment(invoice) return False def _notify_staff_manual_payment(self, invoice): """Create an activity for sales staff when auto-payment is unavailable.""" self.ensure_one() if not self.env.ref('mail.mail_activity_data_todo', raise_if_not_found=False): return self.activity_schedule( 'mail.mail_activity_data_todo', date_deadline=fields.Date.today(), summary=_("Collect rental payment for %s", self.name), note=_( "Automatic payment could not be processed for invoice %s. " "Please collect payment manually.", invoice.name, ), user_id=self.user_id.id or self.env.uid, ) def _send_renewal_confirmation_email(self, renewal_log, payment_ok): """Send renewal confirmation email with invoice + receipt attached.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_renewed', raise_if_not_found=False, ) if not template: return attachment_ids = [] invoice = self.env['account.move'].browse( renewal_log.invoice_id.id ) if renewal_log and renewal_log.invoice_id else None if invoice and payment_ok: attachment_ids = self._generate_invoice_attachments( invoice, 'Renewal', ) template.with_context( payment_ok=payment_ok, renewal_log=renewal_log, ).send_mail( self.id, force_send=True, email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, ) def _send_payment_receipt_email(self, invoice, transaction): """Send payment receipt email after successful collection.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_payment_receipt', raise_if_not_found=False, ) if not template: return attachment_ids = [] if invoice: attachment_ids = self._generate_invoice_attachments( invoice, 'Payment Receipt', ) template.with_context( invoice=invoice, transaction=transaction, ).send_mail( self.id, force_send=True, email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, ) def _send_renewal_reminder_email(self, cancel_token): """Send the 3-day renewal reminder email with cancellation link.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_reminder', raise_if_not_found=False, ) if template: template.with_context( cancel_token=cancel_token, ).send_mail(self.id, force_send=True) def _send_renewal_reminder_sms(self, cancel_token): """Send SMS renewal reminder via RingCentral.""" self.ensure_one() partner = self.partner_id phone = partner.mobile or partner.phone if not phone: _logger.warning( "No phone number for partner %s, skipping SMS reminder for %s.", partner.name, self.name, ) return rc_config = self.env['rc.config']._get_active_config() if not rc_config: _logger.warning("RingCentral not connected, skipping SMS reminder.") return base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', '') cancel_url = f"{base_url}/rental/cancel/{cancel_token}" company_phone = self.env.company.phone or '' message = _( "Your rental %(ref)s renews on %(date)s. " "To cancel, visit %(url)s or call %(phone)s.", ref=self.name, date=self.rental_return_date.strftime('%m/%d/%Y') if self.rental_return_date else '', url=cancel_url, phone=company_phone, ) try: rc_config._send_sms(phone, message) except Exception as e: _logger.error("Failed to send SMS reminder for %s: %s", self.name, e) @api.model def _cron_rental_renewal_reminders(self): """Cron: send renewal reminders based on percentage of rental period.""" today = fields.Date.today() orders = self.search([ ('is_rental_order', '=', True), ('rental_auto_renew', '=', True), ('rental_status', 'in', ('pickup', 'return')), ('rental_next_renewal_date', '>', today), ('rental_reminder_sent', '=', False), ]) for order in orders: if order._has_pending_cancellation(): continue try: target = order._get_reminder_target_date() if not target or today < target: continue cancel_token = order._prepare_renewal_cancellation_token() order._send_renewal_reminder_email(cancel_token) order._send_renewal_reminder_sms(cancel_token) order.write({'rental_reminder_sent': True}) _logger.info( "Sent renewal reminder for %s (target date %s)", order.name, target, ) except Exception as e: _logger.error("Failed to send reminder for %s: %s", order.name, e) # Orders whose return date is more than this many days in the past # are considered stale and will NOT be auto-renewed. This prevents # accidental mass-processing of old rentals when the module is first # installed or when auto-renew is enabled on a historical order. _RENEWAL_STALENESS_DAYS = 3 @api.model def _cron_rental_auto_renewals(self): """Cron: auto-renew rentals that have reached their return date.""" today = fields.Date.today() staleness_cutoff = today - timedelta(days=self._RENEWAL_STALENESS_DAYS) orders = self.search([ ('is_rental_order', '=', True), ('rental_auto_renew', '=', True), ('rental_status', 'in', ('pickup', 'return')), ('rental_next_renewal_date', '<=', today), ('rental_next_renewal_date', '>=', staleness_cutoff), ]) for order in orders: if order._has_pending_cancellation(): _logger.info( "Skipping auto-renewal for %s: pending cancellation request.", order.name, ) continue if ( order.rental_max_renewals > 0 and order.rental_renewal_count >= order.rental_max_renewals ): _logger.info( "Skipping auto-renewal for %s: max renewals reached (%d/%d).", order.name, order.rental_renewal_count, order.rental_max_renewals, ) continue if order._is_short_term_rental(): returned = any( line.qty_returned > 0 for line in order.order_line if line.is_rental and not line.display_type ) if returned: _logger.info( "Skipping auto-renewal for %s: short-term rental already returned.", order.name, ) continue if not order._short_term_grace_expired(): _logger.info( "Skipping auto-renewal for %s: short-term grace period not expired.", order.name, ) continue try: order._process_auto_renewal() except Exception as e: _logger.error("Auto-renewal failed for %s: %s", order.name, e) def action_manual_renewal(self): """Open the manual renewal wizard.""" self.ensure_one() duration = self._get_rental_duration_days() new_start = self.rental_return_date new_return = new_start + timedelta(days=duration) if new_start else False return { 'name': _("Renew Rental"), 'type': 'ir.actions.act_window', 'res_model': 'manual.renewal.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_order_id': self.id, 'default_current_start_date': self.rental_start_date, 'default_current_return_date': self.rental_return_date, 'default_new_start_date': new_start, 'default_new_return_date': new_return, 'default_payment_token_id': self.rental_payment_token_id.id or False, 'default_previous_start_date': self.rental_start_date, 'default_previous_return_date': self.rental_return_date, }, } # ================================================================= # Auto-set sale type for rental orders # ================================================================= @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('is_rental_order') and not vals.get('x_fc_sale_type'): vals['x_fc_sale_type'] = 'rental' vals.setdefault('x_fc_authorizer_required', 'no') return super().create(vals_list) # ================================================================= # Order Confirmation - auto-create deposit invoice # ================================================================= def action_confirm(self): """Override to create invoices and auto-send agreement on confirmation. 1. Rental charges + delivery/services (everything except deposit) 2. Security deposit (separate invoice) 3. Automatically send the rental agreement for signing """ for order in self: if order.is_rental_order and not order.x_fc_sale_type: order.x_fc_sale_type = 'rental' order.x_fc_authorizer_required = 'no' res = super().action_confirm() for order in self: if not order.is_rental_order: continue try: if not order.rental_charges_invoice_id: order._create_rental_charges_invoice() except Exception as e: _logger.error( "Failed to create rental charges invoice for %s: %s", order.name, e, ) try: deposit_lines = order.order_line.filtered(lambda l: l.is_security_deposit) if deposit_lines and not order.rental_deposit_invoice_id: order._create_deposit_invoice() except Exception as e: _logger.error( "Failed to create deposit invoice for %s: %s", order.name, e, ) try: if not order.rental_agreement_signed: order.action_send_rental_agreement() except Exception as e: _logger.error( "Failed to auto-send rental agreement for %s: %s", order.name, e, ) return res # ================================================================= # Agreement # ================================================================= def action_send_rental_agreement(self): """Generate agreement token and send the agreement email.""" self.ensure_one() if not self.rental_agreement_token: self.rental_agreement_token = uuid.uuid4().hex template = self.env.ref( 'fusion_rental.mail_template_rental_agreement', raise_if_not_found=False, ) if template: template.send_mail(self.id, force_send=True) self.message_post(body=_("Rental agreement sent to customer for signing.")) def action_send_card_reauthorization(self): """Send a card reauthorization form link to the customer.""" self.ensure_one() self.rental_agreement_token = uuid.uuid4().hex template = self.env.ref( 'fusion_rental.mail_template_rental_card_reauthorization', raise_if_not_found=False, ) if template: template.send_mail(self.id, force_send=True) self.message_post(body=_( "Card reauthorization form sent to customer." )) def _process_post_signing_payments(self): """Auto-collect payments after the customer signs the agreement. Posts both invoices, collects payment via token, marks deposit as collected, emails invoices with receipts, and logs status. """ self.ensure_one() if not self.rental_payment_token_id: self._notify_staff_manual_payment( self.rental_charges_invoice_id or self.rental_deposit_invoice_id ) return charges_inv = self.rental_charges_invoice_id deposit_inv = self.rental_deposit_invoice_id if not charges_inv or not deposit_inv: try: if not charges_inv: self._create_rental_charges_invoice() charges_inv = self.rental_charges_invoice_id if not deposit_inv: deposit_lines = self.order_line.filtered( lambda l: l.is_security_deposit ) if deposit_lines: self._create_deposit_invoice() deposit_inv = self.rental_deposit_invoice_id except Exception as e: _logger.error( "Invoice creation during post-signing for %s: %s", self.name, e, ) for inv in (charges_inv, deposit_inv): if inv and inv.state == 'cancel': inv.button_draft() if charges_inv and charges_inv.state == 'draft': charges_inv.action_post() if charges_inv and charges_inv.state == 'posted' and charges_inv.amount_residual > 0: ok = self._collect_token_payment_for_invoice(charges_inv) if ok: self._send_invoice_with_receipt(charges_inv, 'rental_charges') else: self._notify_staff_manual_payment(charges_inv) if deposit_inv and deposit_inv.state == 'cancel': deposit_inv.button_draft() if deposit_inv and deposit_inv.state == 'draft': deposit_inv.action_post() if deposit_inv and deposit_inv.state == 'posted' and deposit_inv.amount_residual > 0: ok = self._collect_token_payment_for_invoice(deposit_inv) if ok: self.rental_deposit_status = 'collected' self._send_invoice_with_receipt(deposit_inv, 'security_deposit') else: self._notify_staff_manual_payment(deposit_inv) self.message_post(body=_( "Agreement signed and payments processed. Order ready for delivery." )) def _get_card_last_four(self): """Return the last 4 digits of the stored payment token card.""" self.ensure_one() if self.rental_payment_token_id: name = self.rental_payment_token_id.display_name or '' digits = ''.join(c for c in name if c.isdigit()) if len(digits) >= 4: return digits[-4:] return '' # ================================================================= # Security Deposit # ================================================================= def _get_deposit_product(self): """Return the Security Deposit service product (created via XML data).""" product = self.env.ref( 'fusion_rental.product_security_deposit', raise_if_not_found=False, ) if not product: product = self.env['product.product'].search( [('default_code', '=', 'SECURITY-DEPOSIT')], limit=1, ) if not product: raise UserError(_( "Security Deposit product not found. " "Please upgrade the Fusion Rental module." )) return product def _compute_deposit_amount_for_line(self, line): """Calculate the security deposit amount for a rental order line.""" product = line.product_id.product_tmpl_id dep_type = product.x_fc_security_deposit_type if dep_type == 'fixed': return product.x_fc_security_deposit_amount or 0.0 if dep_type == 'percentage': pct = product.x_fc_security_deposit_percent or 0.0 return (line.price_unit * line.product_uom_qty) * pct / 100.0 return 0.0 def _create_rental_charges_invoice(self): """Create an invoice for all non-deposit lines (rental, delivery, services).""" self.ensure_one() charge_lines = self.order_line.filtered( lambda l: not l.is_security_deposit and not l.display_type ) if not charge_lines: return self.env['account.move'] invoice_vals = self._prepare_invoice() invoice_line_vals = [] for line in charge_lines: inv_line = line._prepare_invoice_line() inv_line['quantity'] = line.product_uom_qty invoice_line_vals.append((0, 0, inv_line)) invoice_vals['invoice_line_ids'] = invoice_line_vals invoice = self.env['account.move'].sudo().create(invoice_vals) self.rental_charges_invoice_id = invoice self.message_post(body=_( "Rental charges invoice %s created.", invoice.name or 'Draft', )) return invoice def _create_deposit_invoice(self): """Create a separate invoice for security deposit lines only.""" self.ensure_one() deposit_product = self._get_deposit_product() deposit_lines = self.order_line.filtered( lambda l: l.product_id == deposit_product ) if not deposit_lines: return self.env['account.move'] invoice_vals = self._prepare_invoice() invoice_line_vals = [] for line in deposit_lines: inv_line = line._prepare_invoice_line() invoice_line_vals.append((0, 0, inv_line)) invoice_vals['invoice_line_ids'] = invoice_line_vals invoice = self.env['account.move'].sudo().create(invoice_vals) self.rental_deposit_invoice_id = invoice self.rental_deposit_status = 'pending' return invoice def _get_deposit_hold_days(self): """Return the configured deposit hold period in days.""" hold = int( self.env['ir.config_parameter'] .sudo() .get_param('fusion_rental.deposit_hold_days', '3') ) return max(hold, 0) def _refund_security_deposit(self): """Initiate security deposit refund with configurable hold period.""" self.ensure_one() if self.rental_deposit_status != 'collected': return if not self.rental_inspection_status: _logger.warning( "Skipping deposit refund for %s: inspection not completed.", self.name, ) return hold_days = self._get_deposit_hold_days() self.write({ 'rental_deposit_status': 'refund_hold', 'rental_deposit_refund_date': fields.Date.today() + timedelta(days=hold_days), }) self.message_post(body=_( "Security deposit refund initiated. Hold period: %d day(s). " "Refund will be processed on %s.", hold_days, self.rental_deposit_refund_date, )) self._send_deposit_refund_initiated_email() def _process_deposit_refund(self): """Process the actual deposit refund via the deposit wizard, send receipt, and auto-close.""" self.ensure_one() deposit_total = 0.0 if self.rental_deposit_invoice_id: deposit_total = self.rental_deposit_invoice_id.amount_total wizard = self.env['deposit.deduction.wizard'].create({ 'order_id': self.id, 'deposit_total': deposit_total, 'action_type': 'full_refund', }) wizard.action_confirm() def _deduct_security_deposit(self, deduction_amount, reason=''): """Deduct from security deposit via the deposit wizard.""" self.ensure_one() deposit_total = 0.0 if self.rental_deposit_invoice_id: deposit_total = self.rental_deposit_invoice_id.amount_total action_type = 'no_refund' if deduction_amount >= deposit_total else 'partial_refund' wizard = self.env['deposit.deduction.wizard'].create({ 'order_id': self.id, 'deposit_total': deposit_total, 'action_type': action_type, 'deduction_amount': deduction_amount, 'reason': reason or _("Damage deduction"), }) wizard.action_confirm() def _create_damage_invoice(self, amount): """Create an additional invoice for damage costs exceeding the deposit.""" self.ensure_one() invoice_vals = self._prepare_invoice() invoice_vals['invoice_line_ids'] = [(0, 0, { 'name': _("Damage charges - %s", self.name), 'quantity': 1, 'price_unit': amount, })] damage_invoice = self.env['account.move'].sudo().create(invoice_vals) damage_invoice.action_post() return damage_invoice # ================================================================= # Deposit UI Actions # ================================================================= def action_create_deposit_invoice(self): """Button: create the security deposit invoice manually.""" self.ensure_one() if self.rental_deposit_invoice_id: raise UserError(_("A deposit invoice already exists for this order.")) deposit_lines = self.order_line.filtered(lambda l: l.is_security_deposit) if not deposit_lines: raise UserError(_( "No security deposit lines found. Add a rental product with a " "security deposit configured on it first." )) invoice = self._create_deposit_invoice() if invoice: self.message_post(body=_( "Security deposit invoice %s created.", invoice.name or 'Draft', )) return { 'type': 'ir.actions.act_window', 'res_model': 'account.move', 'res_id': invoice.id, 'view_mode': 'form', 'target': 'current', } def action_mark_deposit_collected(self): """Button: mark deposit as collected (after verifying invoice is paid).""" self.ensure_one() invoice = self.rental_deposit_invoice_id if not invoice: raise UserError(_("No deposit invoice found. Create one first.")) if invoice.payment_state not in ('paid', 'in_payment'): raise UserError(_( "The deposit invoice %s is not yet paid. " "Collect payment before marking as collected.", invoice.name or 'Draft', )) self.rental_deposit_status = 'collected' self.message_post(body=_("Security deposit marked as collected.")) def action_process_deposit(self): """Open the deposit processing wizard.""" self.ensure_one() if not self.rental_inspection_status: raise UserError(_( "Return inspection must be completed before processing the " "security deposit. Please use the 'Return' button to inspect " "the items first." )) if self.rental_deposit_status not in ('collected', 'refund_hold'): raise UserError(_( "Deposit must be in 'Collected' or 'Refund Hold' status to process." )) deposit_total = 0.0 if self.rental_deposit_invoice_id: deposit_total = self.rental_deposit_invoice_id.amount_total return { 'name': _("Process Security Deposit"), 'type': 'ir.actions.act_window', 'res_model': 'deposit.deduction.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_order_id': self.id, 'default_deposit_total': deposit_total, }, } def action_refund_deposit(self): """Button: initiate the security deposit refund hold period.""" self.ensure_one() if self.rental_deposit_status != 'collected': raise UserError(_( "Deposit must be in 'Collected' status to initiate a refund." )) self._refund_security_deposit() def action_deduct_deposit(self): """Alias: redirect to the unified deposit processing wizard.""" return self.action_process_deposit() def action_force_refund_deposit(self): """Button: skip the hold period and process deposit refund immediately.""" self.ensure_one() if self.rental_deposit_status not in ('collected', 'refund_hold'): raise UserError(_( "Deposit must be in 'Collected' or 'Refund Hold' status to process a refund." )) self.rental_deposit_status = 'refund_hold' self.rental_deposit_refund_date = fields.Date.today() self._process_deposit_refund() def _collect_token_payment_for_invoice(self, invoice): """Charge the stored payment token for any invoice.""" self.ensure_one() if not self.rental_payment_token_id or not invoice: return False try: provider = self.rental_payment_token_id.provider_id token = self.rental_payment_token_id PaymentMethod = self.env['payment.method'].sudo().with_context( active_test=False, ) payment_method = token.payment_method_id if not payment_method: payment_method = PaymentMethod.search( [('code', '=', 'card')], limit=1, ) if not payment_method: payment_method = PaymentMethod.search( [('code', 'in', ('visa', 'mastercard'))], limit=1, ) if not payment_method: _logger.error("No payment method found for token payment on %s", self.name) return False reference = self.env['payment.transaction']._compute_reference( provider.code, prefix=f"{self.name}-{invoice.name or 'INV'}", ) tx = self.env['payment.transaction'].sudo().create({ 'provider_id': provider.id, 'payment_method_id': payment_method.id, 'amount': abs(invoice.amount_residual), 'currency_id': invoice.currency_id.id, 'partner_id': self.partner_id.id, 'reference': reference, 'operation': 'offline', 'token_id': token.id, 'invoice_ids': [(4, invoice.id)], }) tx._poynt_process_token_payment() return tx.state == 'done' except Exception as e: _logger.error("Token payment failed for %s: %s", self.name, e) return False def _send_deposit_refund_initiated_email(self): """Send email notifying customer that deposit refund is being processed.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_deposit_refund_initiated', raise_if_not_found=False, ) if not template: return attachment_ids = [] credit_note = self._find_deposit_credit_note() if credit_note: attachment_ids = self._generate_invoice_attachments( credit_note, 'Deposit Credit Note', ) template.send_mail( self.id, force_send=True, email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, ) def _send_deposit_refund_email(self): """Send the security deposit refund completion email with credit note and receipt.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_deposit_refund', raise_if_not_found=False, ) if not template: return attachment_ids = [] credit_note = self._find_deposit_credit_note() if credit_note: attachment_ids = self._generate_invoice_attachments( credit_note, 'Deposit Refund', ) receipt_ids = self._find_poynt_receipt_attachments(credit_note) attachment_ids.extend(receipt_ids) template.send_mail( self.id, force_send=True, email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, ) def _send_invoice_with_receipt(self, invoice, invoice_type=''): """Send invoice email with the invoice PDF and payment receipt attached. :param invoice: The paid account.move record. :param invoice_type: Label hint ('rental_charges', 'security_deposit', 'renewal', 'damage') for context in the email. """ self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_invoice_receipt', raise_if_not_found=False, ) if not template: return type_label = { 'rental_charges': 'Rental Charges', 'security_deposit': 'Security Deposit', 'renewal': 'Renewal', 'damage': 'Damage Assessment', }.get(invoice_type, 'Invoice') attachment_ids = self._generate_invoice_attachments(invoice, type_label) receipt_ids = self._find_poynt_receipt_attachments(invoice) attachment_ids.extend(receipt_ids) template.with_context( rental_invoice=invoice, rental_invoice_type=invoice_type, ).send_mail( self.id, force_send=True, email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, ) # ================================================================= # Email Attachment Helpers # ================================================================= def _generate_invoice_attachments(self, invoice, type_label='Invoice'): """Render an invoice/credit note PDF and return attachment IDs. :param invoice: The account.move record. :param type_label: Human-readable label for the filename. :returns: List of ir.attachment IDs. """ self.ensure_one() attachment_ids = [] try: inv_report = self.env['ir.actions.report']._get_report_from_name( 'account.report_invoice' ) if not inv_report: inv_report = self.env['ir.actions.report']._get_report_from_name( 'account.report_invoice_with_payments' ) if inv_report: pdf_content, _ = inv_report._render_qweb_pdf( inv_report.report_name, [invoice.id] ) inv_filename = ( f"{self.name} - {type_label} - " f"{invoice.name or 'Draft'}.pdf" ) inv_attach = self.env['ir.attachment'].create({ 'name': inv_filename, 'type': 'binary', 'datas': base64.b64encode(pdf_content), 'res_model': 'sale.order', 'res_id': self.id, 'mimetype': 'application/pdf', }) attachment_ids.append(inv_attach.id) except Exception as e: _logger.warning( "Could not generate invoice PDF for %s: %s", self.name, e, ) return attachment_ids def _find_poynt_receipt_attachments(self, invoice): """Find Poynt receipt PDFs attached to an invoice's chatter. :param invoice: The account.move record. :returns: List of ir.attachment IDs. """ if not invoice: return [] receipts = self.env['ir.attachment'].sudo().search([ ('res_model', '=', 'account.move'), ('res_id', '=', invoice.id), ('name', 'like', 'Payment_Receipt_%'), ('mimetype', '=', 'application/pdf'), ], order='id desc', limit=1) return receipts.ids def _find_deposit_credit_note(self): """Find the credit note for the deposit invoice.""" self.ensure_one() if not self.rental_deposit_invoice_id: return None credit_note = self.env['account.move'].search([ ('reversed_entry_id', '=', self.rental_deposit_invoice_id.id), ('move_type', '=', 'out_refund'), ('state', '=', 'posted'), ], order='id desc', limit=1) return credit_note or None def _generate_agreement_attachment_ids(self): """Return attachment IDs for the signed rental agreement if present.""" self.ensure_one() if not self.rental_agreement_document: return [] existing = self.env['ir.attachment'].search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('name', 'like', '%Rental Agreement%Signed%'), ('mimetype', '=', 'application/pdf'), ], order='id desc', limit=1) return existing.ids def _send_damage_notification_email(self): """Notify customer that technician flagged damage on pickup.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_damage_notification', raise_if_not_found=False, ) if template: template.send_mail(self.id, force_send=True) @api.model def _cron_rental_deposit_refunds(self): """Cron: process deposit refunds after the 3-day hold period.""" today = fields.Date.today() orders = self.search([ ('is_rental_order', '=', True), ('rental_deposit_status', '=', 'refund_hold'), ('rental_deposit_refund_date', '<=', today), ]) for order in orders: try: order._process_deposit_refund() _logger.info("Processed deposit refund for %s", order.name) except Exception as e: _logger.error("Deposit refund failed for %s: %s", order.name, e) # ================================================================= # Marketing Email / Purchase Conversion # ================================================================= def _generate_purchase_coupon(self): """Generate a single-use loyalty coupon for rental-to-purchase conversion.""" self.ensure_one() program = self.env.ref( 'fusion_rental.rental_purchase_loyalty_program', raise_if_not_found=False, ) if not program: _logger.warning("Rental purchase loyalty program not found.") return False rental_amount = sum( line.price_subtotal for line in self.order_line if line.is_rental ) if rental_amount <= 0: return False coupon = self.env['loyalty.card'].create({ 'program_id': program.id, 'partner_id': self.partner_id.id, 'points': rental_amount, }) self.rental_purchase_coupon_id = coupon return coupon def _send_marketing_email(self): """Send the day-7 purchase conversion marketing email.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_marketing', raise_if_not_found=False, ) if template: template.send_mail(self.id, force_send=True) @api.model def _cron_rental_marketing_emails(self): """Cron: send purchase marketing email based on percentage of rental period.""" today = fields.Date.today() orders = self.search([ ('is_rental_order', '=', True), ('state', '=', 'sale'), ('rental_status', 'in', ('pickup', 'return')), ('rental_marketing_email_sent', '=', False), ('rental_start_date', '!=', False), ]) for order in orders: try: target = order._get_marketing_target_date() if not target or today < target: continue order._generate_purchase_coupon() order._send_marketing_email() order.rental_marketing_email_sent = True _logger.info("Sent marketing email for %s (target date %s)", order.name, target) except Exception as e: _logger.error("Marketing email failed for %s: %s", order.name, e) # ================================================================= # Agreement Document # ================================================================= def _generate_and_attach_signed_agreement(self): """Generate signed agreement PDF, store it, attach to chatter, and email to customer.""" self.ensure_one() report_name = 'fusion_rental.report_rental_agreement' report = self.env['ir.actions.report']._get_report_from_name(report_name) if not report: return pdf_content, _report_type = report._render_qweb_pdf(report_name, [self.id]) pdf_b64 = base64.b64encode(pdf_content) partner_name = self.partner_id.name or '' filename = f"{self.name} - {partner_name} - Rental Agreement - Signed.pdf" self.write({ 'rental_agreement_document': pdf_b64, 'rental_agreement_document_filename': filename, }) attachment = self.env['ir.attachment'].create({ 'name': filename, 'type': 'binary', 'datas': pdf_b64, 'res_model': 'sale.order', 'res_id': self.id, 'mimetype': 'application/pdf', }) self.message_post( body=_("Signed rental agreement attached."), attachment_ids=[attachment.id], ) self._send_signed_agreement_email(attachment) def _send_signed_agreement_email(self, attachment): """Email the signed rental agreement PDF to the customer.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_signed_agreement', raise_if_not_found=False, ) if template: template.send_mail( self.id, force_send=True, email_values={'attachment_ids': [attachment.id]}, ) def action_preview_rental_agreement(self): """Open the rental agreement PDF in a preview dialog.""" self.ensure_one() if not self.rental_agreement_document: return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _("No Document"), 'message': _("No signed rental agreement document available."), 'type': 'warning', 'sticky': False, }, } attachment = self.env['ir.attachment'].search([ ('res_model', '=', 'sale.order'), ('res_id', '=', self.id), ('name', 'like', '%Rental Agreement%Signed%'), ], order='id desc', limit=1) if not attachment: attachment = self.env['ir.attachment'].create({ 'name': self.rental_agreement_document_filename or f"{self.name} - {self.partner_id.name or ''} - Rental Agreement - Signed.pdf", 'type': 'binary', 'datas': self.rental_agreement_document, 'res_model': 'sale.order', 'res_id': self.id, 'mimetype': 'application/pdf', }) return { 'type': 'ir.actions.act_url', 'url': f'/web/content/{attachment.id}?download=false', 'target': 'new', } # ================================================================= # Transaction Close # ================================================================= def action_close_rental(self): """Close the rental transaction: delete card token, send thank-you.""" self.ensure_one() if self.rental_closed: return if not self.rental_inspection_status and self.rental_status in ( 'return', 'returned', ): raise UserError(_( "Return inspection must be completed before closing the rental. " "Please use the 'Return' button to inspect the items first." )) for inv in (self.rental_charges_invoice_id, self.rental_deposit_invoice_id): if inv and inv.amount_residual > 0 and inv.state == 'posted': _logger.warning( "Closing rental %s with unpaid invoice %s (residual: %s).", self.name, inv.name, inv.amount_residual, ) if self.rental_payment_token_id: token = self.rental_payment_token_id self.rental_payment_token_id = False token.active = False self.rental_closed = True self._send_thank_you_email() self.message_post(body=_("Rental transaction closed.")) def _get_google_review_url(self): """Get the Google review URL from the warehouse or global setting.""" self.ensure_one() warehouse = self.warehouse_id if warehouse and warehouse.google_review_url: return warehouse.google_review_url return ( self.env['ir.config_parameter'] .sudo() .get_param('fusion_rental.google_review_url', '') ) def _send_thank_you_email(self): """Send the thank-you email with Google review link and signed agreement.""" self.ensure_one() template = self.env.ref( 'fusion_rental.mail_template_rental_thank_you', raise_if_not_found=False, ) if not template: return attachment_ids = self._generate_agreement_attachment_ids() template.with_context( google_review_url=self._get_google_review_url(), ).send_mail( self.id, force_send=True, email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, )