diff --git a/odex25_donation/ensan_donation_request/models/donation_recurring.py b/odex25_donation/ensan_donation_request/models/donation_recurring.py index f917ccfc5..b1fffb712 100644 --- a/odex25_donation/ensan_donation_request/models/donation_recurring.py +++ b/odex25_donation/ensan_donation_request/models/donation_recurring.py @@ -22,6 +22,7 @@ class DonationRecurring(models.Model): string="Next Donation Date", tracking=True, help="The date when the next donation will be processed.", default=lambda self: fields.Date.context_today(self) ) + send_recurring_sms = fields.Boolean() recurring_interval = fields.Integer( string="Repeat Every", default=1, @@ -39,10 +40,11 @@ class DonationRecurring(models.Model): ) active = fields.Boolean(default=True) state = fields.Selection([ + ('new', 'New'), ('active', 'Active'), ('paused', 'Paused'), ('cancel', 'Cancelled'), - ], default='active', string='Status', tracking=True) + ], default='new', string='Status', tracking=True) recurring_line_ids = fields.One2many( 'donation.recurring.line', 'recurring_id', @@ -150,6 +152,7 @@ class DonationRecurring(models.Model): sale_final_vals = sale_draft._convert_to_write(sale_draft._cache) sale_order = self.env['sale.order'].sudo().create(sale_final_vals) + line.sale_order_id = sale_order.id # 4. Prepare Sale Order Line vals line_vals = { 'order_id': sale_order.id, @@ -168,17 +171,18 @@ class DonationRecurring(models.Model): 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 - + confirmed = self._recurring_confirm_sale_order(sale_order) + if confirmed: + return sale_order + return False + + def _recurring_confirm_sale_order(self, order): + order.with_context(skip_donation_sms=not self.send_recurring_sms).action_confirm() self.message_post( - body=_("✅ Sale Order %s created for donation dated %s.") % (sale_order.name, line.date) + body=_("✅ Sale Order %s created for donation dated %s.") % (order.name, order.date_order) ) + return True - 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')) @@ -201,6 +205,26 @@ class DonationRecurring(models.Model): invoice.action_post() self.message_post(body=_("🧾 Invoice %s posted for Sale Order %s.") % (invoice.name, order.name)) return invoice + + def action_activate(self): + for rec in self: + rec.name = self.env['ir.sequence'].next_by_code('donation.recurring') or _('New') + for line in rec.recurring_line_ids: + order = rec._create_sale_order(line) + if not order: + continue + rec.sudo().create_invoice_from_order(order) + rec._advance_next_date() + rec.state = 'active' + 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.")) + + rec._message_sms_with_template( + template=template, + partner_ids=rec.partner_id.ids, + put_in_queue=True + ) def action_pause(self): for record in self: @@ -307,28 +331,6 @@ class DonationRecurring(models.Model): 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': diff --git a/odex25_donation/ensan_sale_management/models/sale_order.py b/odex25_donation/ensan_sale_management/models/sale_order.py index bd15bef07..5170179e3 100644 --- a/odex25_donation/ensan_sale_management/models/sale_order.py +++ b/odex25_donation/ensan_sale_management/models/sale_order.py @@ -100,7 +100,7 @@ class SaleOrder(models.Model): sms_template_id = self.env.ref('ensan_sale_management.sms_template_data_donation') donar_sms_template_id = self.env.ref('ensan_sale_management.sms_template_donors_data_donation') for rec in self: - if rec.state == 'sale': + if rec.state == 'sale' and not self._context.get('skip_donation_sms'): if rec.order_mobile_number: rec._message_sms_with_template( template=sms_template_id, diff --git a/odex25_donation/payment_hyperpay_tokenization/__init__.py b/odex25_donation/payment_hyperpay_tokenization/__init__.py new file mode 100644 index 000000000..19240f4ea --- /dev/null +++ b/odex25_donation/payment_hyperpay_tokenization/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models \ No newline at end of file diff --git a/odex25_donation/payment_hyperpay_tokenization/__manifest__.py b/odex25_donation/payment_hyperpay_tokenization/__manifest__.py new file mode 100644 index 000000000..75b7f917c --- /dev/null +++ b/odex25_donation/payment_hyperpay_tokenization/__manifest__.py @@ -0,0 +1,17 @@ +{ + 'name': 'payment_hyperpay_tokenization', + 'version': '1.0', + 'description': '', + 'summary': '', + 'author': '', + 'website': '', + 'license': 'LGPL-3', + 'category': '', + 'depends': [ + 'base', 'payment_hyperpay' + ], + 'data': [ + 'views/payment_acquirer_views.xml' + ] + +} \ No newline at end of file diff --git a/odex25_donation/payment_hyperpay_tokenization/controllers/__init__.py b/odex25_donation/payment_hyperpay_tokenization/controllers/__init__.py new file mode 100644 index 000000000..deec4a8b8 --- /dev/null +++ b/odex25_donation/payment_hyperpay_tokenization/controllers/__init__.py @@ -0,0 +1 @@ +from . import main \ No newline at end of file diff --git a/odex25_donation/payment_hyperpay_tokenization/controllers/main.py b/odex25_donation/payment_hyperpay_tokenization/controllers/main.py new file mode 100644 index 000000000..c8c67fbe0 --- /dev/null +++ b/odex25_donation/payment_hyperpay_tokenization/controllers/main.py @@ -0,0 +1,147 @@ +import requests +import re + +from odoo.http import route, request, Controller +from odoo.addons.payment_hyperpay.data.payment_icon import payment_icon +from odoo.addons.payment.controllers.portal import PaymentProcessing + + + +TEST_URL = "https://eu-test.oppwa.com" +LIVE_URL = "https://eu-prod.oppwa.com" + +class HyperPayTokenization(Controller): + + @route('/hyperpay/tokens/checkout', type='json', auth='public', website=True, methods=['POST']) + def token_checkout(self, **kwargs): + if not kwargs.get('acquirer_id'): + return {'state': False, 'message': 'Couldn\'t identify acquirer'} + try: + self._save_session_data(kwargs) + checkout_data = self._register_payment_card(kwargs) + return checkout_data + except Exception as er: + return { + 'state': False, + 'message': er + } + + def _save_session_data(self, data): + # save data in session as a form of naive saving + return True + + def _register_payment_card(self, data): + acquirer_id = int(data.get('acquirer_id')) + acquirer = request.env['payment.acquirer'].sudo().search([('id', '=', acquirer_id)]) + payment_icons = acquirer.payment_icon_ids.mapped('name') + data_brands = "VISA" + if (len(payment_icon) > 1): + brands = [payment_icon[i.upper()] for i in payment_icons if i.upper() in payment_icon.keys()] + data_brands = brands and " ".join(brands) or data_brands + base_url = request.httprequest.host_url + payload = { + "entityId": acquirer.hyperpay_merchant_id, + 'createRegistration': True, + } + + payload.update(self._get_hyperpay_token_custom_parameters(data)) + + if acquirer.state == 'test': + domain = TEST_URL + else: + domain = LIVE_URL + + url = f"{domain}/v1/checkouts" + + headers = { + "Authorization": f"Bearer {acquirer.hyperpay_authorization}" + } + + response = requests.post(url=url, data=payload, headers=headers).json() + + result = response.get('result', {}) + result_code = result.get('code') + + if result_code and not re.match(r"^(000\.000\.|000\.100\.1|000\.[36]|000\.400\.1[12]0|000\.400\.0[^3]|000\.400\.100|000\.200)", result_code): + return {'state': False, 'message': result.get('description', '')} + + return_url = f'{base_url}hyperpay/tokens/result?acquirer_id={acquirer_id}' + + return { + 'state': True, + 'checkout_id': response.get('id'), + 'service_domain': domain, + 'base_url': base_url, + 'data_brands': data_brands, + 'return_url': return_url, + } + + def _get_hyperpay_token_custom_parameters(self, data): + reference_id = request.session.get('hyperpay_token_reference_id') + reference_model = request.session.get('hyperpay_token_reference_model') + return { + "customParameters[SHOPPER_acquirer_id]": data.get('acquirer_id', 0), + "customParameters[SHOPPER_hyperpay_token_reference_id]": reference_id, + "customParameters[SHOPPER_hyperpay_token_reference_model]": reference_model, + } + + @route('/hyperpay/tokens/result', type='http', auth='public', website=True) + def token_return(self, **post): + try: + if post.get('transaction_id'): + transaction = request.env['payment.transaction'].sudo().search([('id', '=', int(post.get('transaction_id')))]) + if transaction: + transaction.s2s_do_refund() + else: + acquirer_id = request.env['payment.acquirer'].sudo().search([('id', '=', int(post.get('acquirer_id', 0)))]) + + if acquirer_id.state == 'test': + domain = TEST_URL + else: + domain = LIVE_URL + + url = f"{domain}{post.get('resourcePath')}?entityId={acquirer_id.hyperpay_merchant_id}" + headers = { + "Authorization": f"Bearer {acquirer_id.hyperpay_authorization}" + } + resp = requests.get(url=url, headers=headers).json() + result = resp.get('result', {}) + result_code = result.get('code') + + if result_code and not re.match(r"^(000\.000\.|000\.100\.1|000\.[36]|000\.400\.1[12]0|000\.400\.0[^3]|000\.400\.100|000\.200)", result_code): + # Handle failed cards logic here + return {'state': False, 'message': result.get('description', ''), 'errors': resp.get('parameterErrors', [])} + + # create card record and activate recurring donation + card = resp.get('card', {}) + if not card: + return {'state': False, 'message': 'Card data not found'} + self._post_process_token_return(resp) + except Exception as er: + request.env.cr.rollback() + raise er + + return request.redirect('/my/recurring_donation') + + def _post_process_token_return(self, data): + acquirer_id = int(data.get('customParameters', {}).get('SHOPPER_acquirer_id', 0)) + card = data.get('card', {}) + card_vals = { + 'name': f"{card.get('bin', '')}XXXXXXXXXXXX{card.get('last4Digits', '')}", + 'partner_id': request.env.user.partner_id.id, + 'acquirer_id': acquirer_id, + 'acquirer_ref': data.get('id', ''), + 'hyperpay_payment_brand': data.get('paymentBrand'), + } + + token_id = request.env['payment.token'].create(card_vals) + tx = token_id.validate() + PaymentProcessing.add_payment_transaction(tx) + reference_id = int(data.get('customParameters', {}).get('SHOPPER_hyperpay_token_reference_id', 0)) + reference_model = data.get('customParameters', {}).get('SHOPPER_hyperpay_token_reference_model', '') + if reference_id and reference_model and reference_model in request.env: + record_id = request.env[reference_model].search([('id', '=', reference_id)]) + if record_id and hasattr(record_id, '_post_process_card_tokenization'): + record_id._post_process_card_tokenization(token_id) + return True + diff --git a/odex25_donation/payment_hyperpay_tokenization/models/__init__.py b/odex25_donation/payment_hyperpay_tokenization/models/__init__.py new file mode 100644 index 000000000..f65284264 --- /dev/null +++ b/odex25_donation/payment_hyperpay_tokenization/models/__init__.py @@ -0,0 +1 @@ +from . import payment diff --git a/odex25_donation/payment_hyperpay_tokenization/models/payment.py b/odex25_donation/payment_hyperpay_tokenization/models/payment.py new file mode 100644 index 000000000..9e0fbe837 --- /dev/null +++ b/odex25_donation/payment_hyperpay_tokenization/models/payment.py @@ -0,0 +1,142 @@ +from odoo import models, fields +from odoo.http import request +from odoo.addons.payment.models.payment_acquirer import PaymentToken +import requests +import re +import dateutil +import pytz + +TEST_URL = "https://eu-test.oppwa.com" +LIVE_URL = "https://eu-prod.oppwa.com" + + + +class AcquirerHyperPay(models.Model): + _inherit = 'payment.acquirer' + + hyperpay_s2s_entity_id = fields.Char('Server2Server Entity Id', groups='base.group_user') + +class HyperPayTransaction(models.Model): + _inherit = 'payment.transaction' + + def hyperpay_s2s_do_transaction(self, **kwargs): + self.ensure_one() + + if self.acquirer_id.state == 'test': + domain = TEST_URL + else: + domain = LIVE_URL + + url = f"{domain}/v1/registrations/{self.payment_token_id.acquirer_ref}/payments" + + payload = self._hyperpay_get_s2s_transaction_payload(kwargs) + + headers = { + "Authorization": f"Bearer {self.acquirer_id.hyperpay_authorization}" + } + response = requests.post(url, data=payload, headers=headers) + data = response.json() + return self._hyperpay_s2s_validate_transaction(data) + + def _hyperpay_get_s2s_transaction_payload(self, data): + partner_id = self.env.user.partner_id + base_url = request.httprequest.host_url + lang_code = str(self.env['res.lang'].search([('code', '=', self.env.user.lang)]).iso_code or '').upper() + payload = { + "entityId": self.acquirer_id.hyperpay_s2s_entity_id, + "amount": '%.2f' % self.amount, + "currency": self.currency_id.name, + 'paymentBrand': self.payment_token_id.hyperpay_payment_brand, + "paymentType": "DB", + 'standingInstruction.mode': 'INITIAL', + 'standingInstruction.source': 'CIT', + 'standingInstruction.type': 'UNSCHEDULED', + 'shopperResultUrl': f'{base_url}hyperpay/tokens/result?transaction_id={self.id}', + 'notificationUrl': f'{base_url}hyperpay/tokens/result?transaction_id={self.id}', + "billing.street1": partner_id.street or 'Riyadh', + "billing.street2": partner_id.street2 or '', + "billing.city": partner_id.city or 'Riyadh', + "billing.state": partner_id.state_id.name or 'Riyadh', + "billing.postcode": partner_id.zip or '', + "billing.country": partner_id.country_id.code or 'SA', + "customer.givenName": partner_id.name, + "customer.surname": '', + "customer.email": partner_id.email, + "customer.mobile": partner_id.mobile or partner_id.phone or '', + "customer.phone": partner_id.phone or partner_id.mobile or '', + 'customer.ip': request.httprequest.environ["REMOTE_ADDR"], + 'customer.language': lang_code, + } + if self.type != 'validation': + payload.update({ + 'standingInstruction.mode': 'REPEATED', + 'standingInstruction.source': 'MIT', + 'standingInstruction.initialTransactionId': self.payment_token_id.hyperpay_initial_transaction_id + }) + return payload + + def hyperpay_s2s_do_refund(self, **kwargs): + self.ensure_one() + + if self.acquirer_id.state == 'test': + domain = TEST_URL + else: + domain = LIVE_URL + + url = f"{domain}/v1/payments/{self.acquirer_reference}" + base_url = request.httprequest.host_url + + + payload = { + 'entityId': self.acquirer_id.hyperpay_s2s_entity_id, + 'paymentBrand': self.payment_token_id.hyperpay_payment_brand, + 'paymentType': 'RF', + 'amount': '%.2f' % self.amount, + 'currency': self.currency_id.name, + 'shopperResultUrl': f'{base_url}hyperpay/tokens/result', + } + headers = { + "Authorization": f"Bearer {self.acquirer_id.hyperpay_authorization}" + } + response = requests.post(url, data=payload, headers=headers) + data = response.json() + return self._hyperpay_s2s_validate_transaction(data) + + def _hyperpay_s2s_validate_transaction(self, data): + success_re = r"^(000\.000\.|000\.100\.1|000\.[36]|000\.400\.1[12]0|000\.400\.0[^3]|000\.400\.100)" + pending_re = r"^(000\.200|800\.400\.5|100\.400\.500)" + + result = data.get('result') + result_code = result.get('code') + res = { + 'acquirer_reference': data.get('id'), + 'state_message': result.get('description', '') + } + + if re.match(success_re, result_code): + date_validate = dateutil.parser.parse(data.get('timestamp')).astimezone(pytz.utc).replace(tzinfo=None) + res.update(date=date_validate) + if self.type == 'validation' and not self.payment_token_id.verified: + self.payment_token_id.write({ + 'verified': True, + 'hyperpay_initial_transaction_id': data.get('id', '') + }) + self.payment_token_id.verified = True + self._set_transaction_done() + elif re.match(pending_re, result_code): + self._set_transaction_pending() + else: + self._set_transaction_error(result.get('description', '')) + return self.write(res) + + +class HyperPayToken(models.Model): + _inherit = 'payment.token' + + hyperpay_payment_brand = fields.Char('Payment Brand') + hyperpay_initial_transaction_id = fields.Char() + + +PaymentToken.VALIDATION_AMOUNTS.update({ + 'SAR': 5.00 +}) diff --git a/odex25_donation/payment_hyperpay_tokenization/views/payment_acquirer_views.xml b/odex25_donation/payment_hyperpay_tokenization/views/payment_acquirer_views.xml new file mode 100644 index 000000000..0350c7d43 --- /dev/null +++ b/odex25_donation/payment_hyperpay_tokenization/views/payment_acquirer_views.xml @@ -0,0 +1,16 @@ + + + + + payment.acquirer.view.form + payment.acquirer + + + + + + + + + + diff --git a/odex25_donation/recurring_donation_payment/__init__.py b/odex25_donation/recurring_donation_payment/__init__.py new file mode 100644 index 000000000..19240f4ea --- /dev/null +++ b/odex25_donation/recurring_donation_payment/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models \ No newline at end of file diff --git a/odex25_donation/recurring_donation_payment/__manifest__.py b/odex25_donation/recurring_donation_payment/__manifest__.py new file mode 100644 index 000000000..4c8cc53f4 --- /dev/null +++ b/odex25_donation/recurring_donation_payment/__manifest__.py @@ -0,0 +1,14 @@ +{ + 'name': 'Recurring Donation Payment', + 'version': '1.0', + 'description': '', + 'summary': '', + 'author': 'Abdul Rahman Saber', + 'website': '', + 'license': 'LGPL-3', + 'category': '', + 'depends': [ + 'base', 'ensan_donation_request', 'payment_hyperpay_tokenization' + ], + 'auto_install': True, +} \ No newline at end of file diff --git a/odex25_donation/recurring_donation_payment/controllers/__init__.py b/odex25_donation/recurring_donation_payment/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/odex25_donation/recurring_donation_payment/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/odex25_donation/recurring_donation_payment/controllers/main.py b/odex25_donation/recurring_donation_payment/controllers/main.py new file mode 100644 index 000000000..67b77b95c --- /dev/null +++ b/odex25_donation/recurring_donation_payment/controllers/main.py @@ -0,0 +1,32 @@ +from odoo.http import request + +from odoo.addons.payment_hyperpay_tokenization.controllers.main import HyperPayTokenization + +class RecurringDonationTokenization(HyperPayTokenization): + + def _save_session_data(self, data): + try: + product_id = data.get('product_id') + amount = data.get('amount') + sms_status = data.get('sms_status') + frequency = data.get('frequency') + + if not all([product_id, amount, frequency]): + return {'state': False, 'message': 'invalid payload'} + + recurring_donation_id = request.env['donation.recurring'].sudo().create({ + 'recurring_line_ids': [(0, 0, { + 'product_id': product_id, + 'quantity': 1, + 'price_unit': amount, + })], + 'partner_id': request.env.user.partner_id.id, + 'frequency': frequency, + 'send_recurring_sms': sms_status + }) + request.session['hyperpay_token_reference_id'] = recurring_donation_id.id + request.session['hyperpay_token_reference_model'] = recurring_donation_id._name + return True + except Exception as e: + request.env.cr.rollback() + raise e \ No newline at end of file diff --git a/odex25_donation/recurring_donation_payment/models/__init__.py b/odex25_donation/recurring_donation_payment/models/__init__.py new file mode 100644 index 000000000..a2935e633 --- /dev/null +++ b/odex25_donation/recurring_donation_payment/models/__init__.py @@ -0,0 +1 @@ +from . import recurring_donation \ No newline at end of file diff --git a/odex25_donation/recurring_donation_payment/models/recurring_donation.py b/odex25_donation/recurring_donation_payment/models/recurring_donation.py new file mode 100644 index 000000000..ec8e63352 --- /dev/null +++ b/odex25_donation/recurring_donation_payment/models/recurring_donation.py @@ -0,0 +1,37 @@ +from odoo import models, fields, _ +from odoo.exceptions import ValidationError + +class RecurringDonation(models.Model): + _inherit = 'donation.recurring' + + preferred_payment_token_id = fields.Many2one('payment.token', domain="[('partner_id', '=', partner_id)]") + + def _post_process_card_tokenization(self, token_id): + self.write({ + 'preferred_payment_token_id': token_id.id + }) + if token_id.verified: + self.action_activate() + + def _recurring_confirm_sale_order(self, order): + transaction_id = self._create_payment_transaction(order) + if transaction_id.state == 'done': + return super()._recurring_confirm_sale_order(order) + return False + + def _create_payment_transaction(self, order): + payment_token_id = self._get_recurring_payment_token_id() + if not payment_token_id: + raise ValidationError(_('This partner doesn\'t have registered payment tokens')) + vals = { + 'type': 'form_save', + 'payment_token_id': payment_token_id + } + return order._create_payment_transaction(vals) + + def _get_recurring_payment_token_id(self): + self.ensure_one() + if self.preferred_payment_token_id: + return self.preferred_payment_token_id.id + + return self.env['payment.token'].sudo().search([('partner_id', '=', self.partner_id.id), ('verified', '=', True)], limit=1).id