diff --git a/odex25_donation/ensan_donation_request/__manifest__.py b/odex25_donation/ensan_donation_request/__manifest__.py
index 4c8407814..bcd078211 100644
--- a/odex25_donation/ensan_donation_request/__manifest__.py
+++ b/odex25_donation/ensan_donation_request/__manifest__.py
@@ -12,6 +12,7 @@
'data/sequence.xml',
'data/donation_stage_data.xml',
'data/donation_priority_data.xml',
+ 'data/donation_recurring_cron.xml',
'views/donation_request_views.xml',
'views/donation_stage_views.xml',
'views/donation_priority_views.xml',
@@ -19,6 +20,7 @@
'views/res_config_settings_views.xml',
'report/campaign_report.xml',
'views/product_public_category.xml',
+ 'views/donation_recurring_views.xml',
'views/menus.xml',
],
'application': True,
diff --git a/odex25_donation/ensan_donation_request/data/donation_recurring_cron.xml b/odex25_donation/ensan_donation_request/data/donation_recurring_cron.xml
new file mode 100644
index 000000000..fd20a45cf
--- /dev/null
+++ b/odex25_donation/ensan_donation_request/data/donation_recurring_cron.xml
@@ -0,0 +1,15 @@
+
+
+ cron_recurring_create_invoice()
+
+ Process Recurring Donations
+
+ code
+ model.cron_recurring_create_donations()
+
+ 1
+ days
+ -1
+
+
+
diff --git a/odex25_donation/ensan_donation_request/data/sequence.xml b/odex25_donation/ensan_donation_request/data/sequence.xml
index 115ab9d84..f364cf083 100644
--- a/odex25_donation/ensan_donation_request/data/sequence.xml
+++ b/odex25_donation/ensan_donation_request/data/sequence.xml
@@ -7,4 +7,11 @@
5
+
+ Donation Recurring Sequence
+ donation.recurring
+ DR/
+ 5
+
+
diff --git a/odex25_donation/ensan_donation_request/models/__init__.py b/odex25_donation/ensan_donation_request/models/__init__.py
index 751ef34c6..0b9fcf24f 100644
--- a/odex25_donation/ensan_donation_request/models/__init__.py
+++ b/odex25_donation/ensan_donation_request/models/__init__.py
@@ -1,4 +1,6 @@
from . import donation_request
+from . import donation_recurring
+from . import sale_order
from . import donation_stage
from . import donation_priority
from . import product_template
diff --git a/odex25_donation/ensan_donation_request/models/donation_recurring.py b/odex25_donation/ensan_donation_request/models/donation_recurring.py
new file mode 100644
index 000000000..4d79ff1dc
--- /dev/null
+++ b/odex25_donation/ensan_donation_request/models/donation_recurring.py
@@ -0,0 +1,366 @@
+from odoo import models, fields, api, _
+from odoo.exceptions import ValidationError,UserError
+from dateutil.relativedelta import relativedelta
+
+
+class DonationRecurring(models.Model):
+ _name = "donation.recurring"
+ _description = "Donation Recurring"
+ _rec_name = "name"
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ name = fields.Char(string="Name", required=True, tracking=True, copy=False, default=_('New'))
+ partner_id = fields.Many2one(comodel_name="res.partner", required=True, tracking=True)
+ partner_mobile = fields.Char(string="Mobile Number", related='partner_id.mobile')
+ currency_id = fields.Many2one('res.currency', related='partner_id.currency_id', readonly=True)
+ total_amount = fields.Monetary(string="Total Amount", compute='_compute_total_amount', store=True,
+ currency_field='currency_id', tracking=True,
+ help="Total amount")
+ date_start = fields.Date(string="Date Start", default=lambda self: fields.Date.context_today(self), tracking=True)
+ date_end = fields.Date(string="Date End", tracking=True)
+ recurring_next_date = fields.Date(
+ string="Next Donation Date", tracking=True,
+ help="The date when the next donation will be processed.", default=lambda self: fields.Date.context_today(self)
+ )
+ recurring_interval = fields.Integer(
+ string="Repeat Every",
+ default=1,
+ required=True, tracking=True
+ )
+ frequency = fields.Selection(
+ [('daily', 'Day(s)'),
+ ('weekly', 'Week(s)'),
+ ('monthly', 'Month(s)'),
+ ],
+ string='Recurrence Frequency',
+ default='monthly',
+ required=True,
+ help='How often the donation should recur.', tracking=True
+ )
+ active = fields.Boolean(default=True)
+ state = fields.Selection([
+ ('active', 'Active'),
+ ('paused', 'Paused'),
+ ('cancel', 'Cancelled'),
+ ], default='active', string='Status', tracking=True)
+ recurring_line_ids = fields.One2many(
+ 'donation.recurring.line',
+ 'recurring_id',
+ string="Donation Lines"
+ )
+ sale_order_ids = fields.One2many(
+ 'sale.order', 'donation_recurring_id',
+ string="Related Sale Orders"
+ )
+ sale_order_count = fields.Integer(string="Sale Orders", compute="_compute_sale_order_count", store=True)
+ invoice_count = fields.Integer(string="Invoices", compute="_compute_invoice_count", store=True)
+
+ @api.depends('sale_order_ids')
+ def _compute_sale_order_count(self):
+ for rec in self:
+ rec.sale_order_count = len(rec.sale_order_ids)
+
+ @api.depends('sale_order_ids.invoice_ids')
+ def _compute_invoice_count(self):
+ for rec in self:
+ invoices = rec.sale_order_ids.mapped('invoice_ids')
+ rec.invoice_count = len(invoices)
+
+ def action_view_sale_orders(self):
+ self.ensure_one()
+ sale_orders = self.env['sale.order'].search([('donation_recurring_id', '=', self.id)])
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Sales Orders'),
+ 'view_mode': 'tree,form',
+ 'res_model': 'sale.order',
+ 'domain': [('id', 'in', sale_orders.ids)],
+ 'context': {'default_partner_id': self.partner_id.id, 'create': False,
+ 'edit': False, },
+ }
+
+ def action_view_invoices(self):
+ self.ensure_one()
+ invoices = self.sale_order_ids.mapped('invoice_ids')
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Invoices'),
+ 'view_mode': 'tree,form',
+ 'res_model': 'account.move',
+ 'domain': [('id', 'in', invoices.ids)],
+ 'context': {
+ 'default_move_type': 'out_invoice',
+ 'create': False,
+ 'edit': False,
+ },
+ }
+
+ @api.depends('recurring_line_ids', 'recurring_line_ids.total_amount')
+ def _compute_total_amount(self):
+ for rec in self:
+ rec.total_amount = sum(line.total_amount for line in rec.recurring_line_ids)
+
+ @api.onchange('recurring_interval', 'frequency')
+ def _onchange_frequency_or_interval(self):
+ for rec in self:
+ last_line = rec.recurring_line_ids.sorted('date')[-1] if rec.recurring_line_ids else None
+ if last_line:
+ interval = rec.recurring_interval or 1
+ if rec.frequency == 'daily':
+ rec.recurring_next_date = last_line.date + relativedelta(days=interval)
+ elif rec.frequency == 'weekly':
+ rec.recurring_next_date = last_line.date + relativedelta(weeks=interval)
+ elif rec.frequency == 'monthly':
+ rec.recurring_next_date = last_line.date + relativedelta(months=interval)
+
+ def _create_donation_line(self):
+ for rec in self:
+ last_line = rec.recurring_line_ids.sorted('date')[-1] if rec.recurring_line_ids else None
+ if not last_line:
+ continue
+
+ new_line = self.env['donation.recurring.line'].create({
+ 'recurring_id': rec.id,
+ 'product_id': last_line.product_id.id,
+ 'quantity': last_line.quantity,
+ 'price_unit': last_line.price_unit,
+ 'date': fields.Date.context_today(self),
+ })
+ return new_line
+
+ def _create_sale_order(self, line):
+ self.ensure_one()
+
+ if not self.partner_id or not line or line.sale_order_id:
+ return
+
+ # 1. Prepare Sale Order vals
+ sale_vals = {
+ 'partner_id': self.partner_id.id,
+ 'currency_id': self.currency_id.id,
+ 'date_order': line.date,
+ 'donation_recurring_id': self.id,
+ }
+
+ # 2. Simulate sale.order with .new() and trigger onchanges
+ sale_draft = self.env['sale.order'].new(sale_vals)
+ sale_draft.onchange_partner_id()
+
+ # 3. Convert to create dict
+ sale_final_vals = sale_draft._convert_to_write(sale_draft._cache)
+ sale_order = self.env['sale.order'].sudo().create(sale_final_vals)
+
+ # 4. Prepare Sale Order Line vals
+ line_vals = {
+ 'order_id': sale_order.id,
+ 'product_id': line.product_id.id,
+ 'product_uom_qty': line.quantity,
+ 'qty_invoiced': line.quantity,
+ 'qty_delivered': line.quantity,
+ 'price_unit': line.price_unit,
+ 'name': line.product_id.name or 'Donation Item',
+ }
+
+ # 5. Simulate sale.order.line with .new() and trigger onchanges
+ line_draft = self.env['sale.order.line'].new(line_vals)
+
+ # 6. Convert to create dict
+ line_final_vals = line_draft._convert_to_write(line_draft._cache)
+ self.env['sale.order.line'].sudo().create(line_final_vals)
+
+ # 7. Confirm the order
+ sale_order.action_confirm()
+
+ # 8. Link the recurring line
+ line.sale_order_id = sale_order.id
+
+ self.message_post(
+ body=_("โ
Sale Order %s created for donation dated %s.") % (sale_order.name, line.date)
+ )
+
+ return sale_order
+
+ # def process_delivery_from_order(self, order, quantity_done=None):
+ # picking = order.picking_ids.filtered(lambda p: p.state not in ('done', 'cancel'))
+ # if not picking:
+ # return False
+ #
+ # for move in picking.move_lines:
+ # move.quantity_done = quantity_done or move.product_uom_qty
+ #
+ # result = picking.button_validate()
+ # if isinstance(result, dict) and result.get('res_model'):
+ # wizard = self.env[result['res_model']].browse(result['res_id'])
+ # wizard.process()
+ #
+ # self.message_post(body=_("๐ฆ Delivery validated for Sale Order %s.") % order.name)
+ # return picking
+
+ def create_invoice_from_order(self, order):
+ invoice = order._create_invoices()
+ invoice.action_post()
+ self.message_post(body=_("๐งพ Invoice %s posted for Sale Order %s.") % (invoice.name, order.name))
+ return invoice
+
+ def action_pause(self):
+ for record in self:
+ record.state = 'paused'
+ template = self.env.company.donation_recurring_paused_sms_template_id
+ if not template:
+ raise ValidationError(
+ _("โ ๏ธ SMS template for 'Paused Recurring Donation' is not configured in Company settings."))
+ record._message_sms_with_template(
+ template=template,
+ partner_ids=record.partner_id.ids,
+ put_in_queue=True
+ )
+
+ def action_resume(self):
+ today = fields.Date.context_today(self)
+ for record in self:
+ if record.state != 'active':
+ old_date = record.recurring_next_date
+ record.state = 'active'
+
+ if old_date and old_date < today:
+ record.recurring_next_date = today
+ record.message_post(
+ body=_(
+ "๐ Recurring profile resumed. "
+ "Next donation date was in the past (%s) and has been reset to today (%s)."
+ ) % (old_date, today)
+ )
+ else:
+ record.message_post(
+ body=_("๐ Recurring profile resumed. Next donation scheduled on %s.") % old_date
+ )
+ template = self.env.company.donation_recurring_resumed_sms_template_id
+ if not template:
+ raise ValidationError(
+ _("โ ๏ธ SMS template for 'Resumed Recurring Donation' is not configured in Company settings.")
+ )
+ record._message_sms_with_template(
+ template=template,
+ partner_ids=record.partner_id.ids,
+ put_in_queue=True
+ )
+
+ def action_cancel(self):
+ for record in self:
+ record.state = 'cancel'
+ record.active = False
+
+ def action_reset_to_active(self):
+ for record in self:
+ record.state = 'active'
+
+ @api.model
+ def cron_recurring_create_donations(self, date_ref=None):
+ return self._cron_recurring_create(date_ref)
+
+ @api.model
+ def _cron_recurring_create(self, date_ref=None):
+ if not date_ref:
+ date_ref = fields.Date.context_today(self)
+ domain = self._get_donations_to_process_domain(date_ref)
+
+ records = self.search(domain)
+ for rec in records:
+ try:
+ unprocessed_lines = rec.recurring_line_ids.filtered(lambda l: not l.sale_order_id)
+ if unprocessed_lines:
+ for line in unprocessed_lines:
+ order = rec._create_sale_order(line)
+ if order:
+ rec.create_invoice_from_order(order)
+ new_line = rec._create_donation_line()
+ if not new_line:
+ continue
+ order = rec._create_sale_order(new_line)
+ if order:
+ rec.create_invoice_from_order(order)
+ rec._advance_next_date()
+ except Exception as e:
+ rec.message_post(body=_("โ Unexpected error:
%s
") % str(e))
+ continue
+
+ return True
+
+ @api.model
+ def _get_donations_to_process_domain(self, date_ref=None):
+ if not date_ref:
+ date_ref = fields.Date.context_today(self)
+ return [
+ ('recurring_next_date', '<=', date_ref),
+ ('state', '=', 'active'),
+ ('active', '=', True),
+ ]
+
+ def _advance_next_date(self):
+ for rec in self:
+ interval = rec.recurring_interval or 1
+ if rec.frequency == 'daily':
+ rec.recurring_next_date += relativedelta(days=interval)
+ elif rec.frequency == 'weekly':
+ rec.recurring_next_date += relativedelta(weeks=interval)
+ elif rec.frequency == 'monthly':
+ rec.recurring_next_date += relativedelta(months=interval)
+
+ @api.model
+ def create(self, vals):
+ if vals.get('name', _('New')) == _('New'):
+ vals['name'] = self.env['ir.sequence'].next_by_code('donation.recurring') or _('New')
+ res = super(DonationRecurring, self).create(vals)
+ for line in res.recurring_line_ids:
+ order = res._create_sale_order(line)
+ if not order:
+ continue
+ res.sudo().create_invoice_from_order(order)
+ res._advance_next_date()
+ template = self.env.company.donation_recurring_created_sms_template_id
+ if not template:
+ raise ValidationError(_("โ ๏ธ SMS template for 'Send When Created' is not configured in Company settings."))
+
+ res._message_sms_with_template(
+ template=template,
+ partner_ids=res.partner_id.ids,
+ put_in_queue=True
+ )
+ return res
+
+ def unlink(self):
+ for rec in self:
+ if rec.state == 'active':
+ raise UserError(
+ _("โ Cannot delete a profile while it is in 'Active' state. Please cancel or pause it first."))
+
+ if rec.sale_order_ids:
+ raise UserError(_("โ Cannot delete a profile that has related Sale Orders. Archive it instead."))
+ template = self.env.company.donation_recurring_deleted_sms_template_id
+ if template:
+ rec._message_sms_with_template(
+ template=template,
+ partner_ids=rec.partner_id.ids,
+ put_in_queue=True
+ )
+ return super(DonationRecurring, self).unlink()
+
+
+class DonationRecurringLine(models.Model):
+ _name = 'donation.recurring.line'
+ _description = 'Recurring Donation Line'
+
+ recurring_id = fields.Many2one('donation.recurring', string="Recurring Profile")
+ product_id = fields.Many2one('product.product', string="Product", domain=[('is_recurring_donation', '=', True)],
+ required=True)
+ quantity = fields.Float(string="Quantity", default=1.0, digits='Product Unit of Measure')
+ price_unit = fields.Float(string="Unit Price", digits='Product Price')
+ total_amount = fields.Monetary(string="Total Amount", compute="_compute_total_amount", store=True)
+ currency_id = fields.Many2one('res.currency', related='recurring_id.partner_id.currency_id', readonly=True)
+ date = fields.Date(string="Donation Date", default=fields.Date.context_today)
+ sale_order_id = fields.Many2one('sale.order', string="Related Sale Order", readonly=True)
+
+ @api.depends('quantity', 'price_unit')
+ def _compute_total_amount(self):
+ for line in self:
+ line.total_amount = line.quantity * line.price_unit
diff --git a/odex25_donation/ensan_donation_request/models/donation_request.py b/odex25_donation/ensan_donation_request/models/donation_request.py
index e857a86fc..81481540e 100644
--- a/odex25_donation/ensan_donation_request/models/donation_request.py
+++ b/odex25_donation/ensan_donation_request/models/donation_request.py
@@ -57,7 +57,7 @@ class DonationRequest(models.Model):
@api.depends('remaining_amount')
def _compute_stage_id(self):
for rec in self:
- if rec.remaining_amount <= 0:
+ if rec.product_id and rec.remaining_amount <= 0:
new_stage = self.env.ref('ensan_donation_request.stage_done')
else:
new_stage = rec.stage_id
diff --git a/odex25_donation/ensan_donation_request/models/product_template.py b/odex25_donation/ensan_donation_request/models/product_template.py
index 4f537cb22..9ce171f58 100644
--- a/odex25_donation/ensan_donation_request/models/product_template.py
+++ b/odex25_donation/ensan_donation_request/models/product_template.py
@@ -5,3 +5,7 @@ class ProductTemplate(models.Model):
donation_request_id = fields.Many2one('donation.request', ondelete='restrict')
hide_from_shop_front = fields.Boolean()
+ is_recurring_donation = fields.Boolean(
+ string='Is Recurring Donation Product',
+ help='Enable this if the product can be used in recurring donations.'
+ )
diff --git a/odex25_donation/ensan_donation_request/models/res_config_settings.py b/odex25_donation/ensan_donation_request/models/res_config_settings.py
index 45f889f20..5d98182c8 100644
--- a/odex25_donation/ensan_donation_request/models/res_config_settings.py
+++ b/odex25_donation/ensan_donation_request/models/res_config_settings.py
@@ -1,12 +1,39 @@
from odoo import models, fields
+
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
- donation_request_confirmation_sms_template_id = fields.Many2one('sms.template', required=True, related='company_id.donation_request_confirmation_sms_template_id', readonly=False)
+ donation_request_confirmation_sms_template_id = fields.Many2one('sms.template', required=True,
+ related='company_id.donation_request_confirmation_sms_template_id',
+ readonly=False)
+ donation_recurring_created_sms_template_id = fields.Many2one(
+ 'sms.template', related='company_id.donation_recurring_created_sms_template_id', readonly=False
+ )
+ donation_recurring_paused_sms_template_id = fields.Many2one(
+ 'sms.template', related='company_id.donation_recurring_paused_sms_template_id', readonly=False
+ )
+ donation_recurring_resumed_sms_template_id = fields.Many2one(
+ 'sms.template', related='company_id.donation_recurring_resumed_sms_template_id', readonly=False
+ )
+ donation_recurring_cancelled_sms_template_id = fields.Many2one(
+ 'sms.template', related='company_id.donation_recurring_cancelled_sms_template_id', readonly=False
+ )
+ donation_recurring_charged_sms_template_id = fields.Many2one(
+ 'sms.template', related='company_id.donation_recurring_charged_sms_template_id', readonly=False
+ )
+ donation_recurring_deleted_sms_template_id = fields.Many2one(
+ 'sms.template', related='company_id.donation_recurring_deleted_sms_template_id', readonly=False
+ )
class ResCompany(models.Model):
_inherit = 'res.company'
- donation_request_confirmation_sms_template_id = fields.Many2one('sms.template', required=True)
\ No newline at end of file
+ donation_request_confirmation_sms_template_id = fields.Many2one('sms.template', required=True)
+ donation_recurring_created_sms_template_id = fields.Many2one('sms.template')
+ donation_recurring_paused_sms_template_id = fields.Many2one('sms.template')
+ donation_recurring_resumed_sms_template_id = fields.Many2one('sms.template')
+ donation_recurring_cancelled_sms_template_id = fields.Many2one('sms.template')
+ donation_recurring_charged_sms_template_id = fields.Many2one('sms.template')
+ donation_recurring_deleted_sms_template_id = fields.Many2one('sms.template')
diff --git a/odex25_donation/ensan_donation_request/models/sale_order.py b/odex25_donation/ensan_donation_request/models/sale_order.py
new file mode 100644
index 000000000..cf1a8c5c0
--- /dev/null
+++ b/odex25_donation/ensan_donation_request/models/sale_order.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api,_
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ donation_recurring_id = fields.Many2one('donation.recurring', string="Recurring Donation")
\ No newline at end of file
diff --git a/odex25_donation/ensan_donation_request/security/ir.model.access.csv b/odex25_donation/ensan_donation_request/security/ir.model.access.csv
index 7444341e0..049f04f1a 100644
--- a/odex25_donation/ensan_donation_request/security/ir.model.access.csv
+++ b/odex25_donation/ensan_donation_request/security/ir.model.access.csv
@@ -1,5 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_donation_request,access_donation_request,model_donation_request,base.group_user,1,1,1,1
+access_donation_recurring,access_donation_recurring,model_donation_recurring,base.group_user,1,1,1,1
+access_donation_recurring_line,access_donation_recurring_line,model_donation_recurring_line,base.group_user,1,1,1,1
access_donation_request_public,access_donation_request_public,model_donation_request,base.group_public,1,0,0,0
access_donation_request_portal,access_donation_request_portal,model_donation_request,base.group_portal,1,0,0,0
access_donation_stage,access_donation_stage,model_donation_stage,base.group_user,1,1,1,1
diff --git a/odex25_donation/ensan_donation_request/views/donation_recurring_views.xml b/odex25_donation/ensan_donation_request/views/donation_recurring_views.xml
new file mode 100644
index 000000000..1798b5bf4
--- /dev/null
+++ b/odex25_donation/ensan_donation_request/views/donation_recurring_views.xml
@@ -0,0 +1,193 @@
+
+
+
+ donation.recurring.form
+ donation.recurring
+
+
+
+
+
+
+ donation.recurring.kanban
+ donation.recurring
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Amount:
+
+
+
+ Remaining:
+
+
+
+
+
+
+
+
+
+
+
+ donation.recurring.tree
+ donation.recurring
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Recurring Donations
+ donation.recurring
+ tree,kanban,form
+
+
\ No newline at end of file
diff --git a/odex25_donation/ensan_donation_request/views/menus.xml b/odex25_donation/ensan_donation_request/views/menus.xml
index 61b2fad2b..5e24a1de4 100644
--- a/odex25_donation/ensan_donation_request/views/menus.xml
+++ b/odex25_donation/ensan_donation_request/views/menus.xml
@@ -1,31 +1,36 @@