Hello,
We are working with Odoo 18 Community using a custom-made pos_payment module.
We implemented logic to mark a card_payment attempt to avoid stale orders being executed again on other requests.
This works for preventing duplicate transactions.
However, we ran into a side effect:
- When an order is canceled in POS and then retried from the frontend, the retry succeeds without sending a request to the server.
- This seems to happen because the canceled order is still considered “marked,” so the retry logic bypasses the server call.
Question:
How can we adjust the logic so that:
- Stale card_payment attempts are still blocked.
- Canceled orders can be retried properly, sending the request to the server again.
python
import logging
import requests
from datetime import timedelta
from odoo import models, fields, api
from requests.exceptions import HTTPError
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class PosOrder(models.Model):
_inherit = 'pos.order'
fiscal_printed = fields.Boolean(string="Fiscal Printed", default=False)
is_fiscalized = fields.Boolean(string="Fiscalized", default=False)
card_payment_attempted = fields.Boolean(string="Card Payment Attempted", default=False)
@api.model
def sync_from_ui(self, orders):
_logger.info("POSCP → received %s orders", len(orders))
res = super().sync_from_ui(orders)
# Ensure keys POS expects (prevents JS crash)
if isinstance(res, dict):
res.setdefault('pos.order', [])
res.setdefault('pos.order.line', [])
# Collect newly created order IDs
order_ids = [e['id'] for e in res.get('pos.order', []) if isinstance(e, dict) and e.get('id')]
_logger.info("POSCP → new order IDs from super(): %s", order_ids)
if not order_ids:
return res
# DEBUG: log state/flags for ALL new orders before filtering
now = fields.Datetime.now()
for o in self.env['pos.order'].browse(order_ids):
age = None
ref_dt = o.date_order or o.create_date
if ref_dt:
age = (now - ref_dt).total_seconds()
pay_mix = [(p.payment_method_id.journal_id.type, float(p.amount)) for p in o.payment_ids]
_logger.info(
"POSCP • Order probe id=%s name=%s state=%s is_fiscalized=%s card_payment_attempted=%s "
"fiscal_printed=%s amount_total=%.2f age_sec=%s payments=%s",
o.id, o.name, o.state, o.is_fiscalized, o.card_payment_attempted,
o.fiscal_printed, float(o.amount_total), age, pay_mix
)
# Optional: ignore queued orders older than N minutes (UNCOMMENT to enforce)
# max_age = timedelta(minutes=3)
def is_fresh(o):
# base predicate
ok = (o.state == 'paid' and not o.is_fiscalized and not o.card_payment_attempted)
# age gate (optional)
# if ok and (o.date_order or o.create_date):
# ok = (now - (o.date_order or o.create_date)) <= max_age
return ok
fresh_orders = self.env['pos.order'].browse(order_ids).filtered(is_fresh)
_logger.info("POSCP → fresh_orders picked: %s", [o.id for o in fresh_orders])
for order in fresh_orders:
pay_cash = order.payment_ids.filtered(lambda p: p.payment_method_id.journal_id.type == 'cash')
pay_other = order.payment_ids.filtered(lambda p: p.payment_method_id.journal_id.type != 'cash')
_logger.info(
"POSCP → handling id=%s name=%s cash=%s other=%s attempted=%s fiscalized=%s",
order.id, order.name,
[(p.id, float(p.amount)) for p in pay_cash],
[(p.id, float(p.amount)) for p in pay_other],
order.card_payment_attempted, order.is_fiscalized
)
if pay_other:
try:
_logger.info("POSCP → _send_card_payment(%s)", order.name)
order._send_card_payment()
except UserError:
raise
except Exception as e:
_logger.exception("POSCP ✖ card error for %s: %s", order.name, e)
raise UserError(f"Card payment failed: {e}")
if pay_cash:
try:
_logger.info("POSCP → _send_ecr_receipt(%s)", order.name)
order._send_ecr_receipt()
except Exception as e:
_logger.exception("POSCP ✖ cash error for %s: %s", order.name, e)
raise UserError(f"Cash receipt printing failed: {e}")
order.write({'fiscal_printed': True})
self.env.cr.commit()
_logger.info("POSCP → marked fiscal_printed on id=%s", order.id)
return res
def _send_card_payment(self):
self.ensure_one()
if self.card_payment_attempted:
_logger.warning("POSCP ↻ already attempted card for %s – skipping", self.name)
return
self.write({'card_payment_attempted': True})
self.env.cr.commit()
_logger.info("POSCP → set card_payment_attempted=True for id=%s", self.id)
cfg = self.session_id.config_id
url = cfg.card_api_url
api_key = cfg.card_api_key
missing = [a for a in ('card_api_url', 'card_api_key') if not getattr(cfg, a)]
if missing:
raise ValueError("Missing POS config fields: %s" % ','.join(missing))
items = [{
"text1": ln.product_id.name,
"text2": ln.product_id.description_sale or "",
"sign": "+",
"price": float(ln.price_unit),
"qty": int(ln.qty),
"percent": int(ln.discount or 0),
"netto": 0,
} for ln in self.lines]
payload = {
"unicSaleNum": self._get_unic_sale_num(),
"items": items,
"payment": {"method": "P", "amountIn": float(self.amount_total)},
}
headers = {"Content-Type": "application/json", "X-API-Key": api_key}
endpoint = url.rstrip('/') + '/api/cardpayment'
_logger.info("POSCP → POST %s payload=%s", endpoint, payload)
try:
resp = requests.post(endpoint, json=payload, headers=headers, timeout=60)
resp.raise_for_status()
except HTTPError as httpe:
status = getattr(httpe.response, 'status_code', None)
if status in (400, 402):
try:
reason = httpe.response.json().get('error', 'Card was declined')
except Exception:
reason = 'Card was declined'
# self.write({'card_payment_attempted': False})
# self.env.cr.commit()
raise UserError(f"Payment declined: {reason}")
raise
data = resp.json()
_logger.info("POSCP ← card API response: %s", data)
if data.get('status') in ('declined', 'error', 'failed'):
reason = data.get('message') or data.get('error') or 'Card was declined'
raise UserError(f"Payment declined: {reason}")
self.write({'is_fiscalized': True})
self.env.cr.commit()
_logger.info("POSCP → set is_fiscalized=True for id=%s", self.id)
return data
def _send_ecr_receipt(self):
self.ensure_one()
cfg = self.session_id.config_id
url = cfg.card_api_url
api_key = cfg.card_api_key
if not url or not api_key:
raise UserError("Missing receipt configuration on POS '%s'" % cfg.name)
items = [{
"text1": ln.product_id.name,
"text2": ln.product_id.description_sale or "",
"sign": "+",
"price": float(ln.price_unit),
"qty": int(ln.qty),
"percent": int(ln.discount or 0),
"netto": 0,
} for ln in self.lines]
req = {"unicSaleNum": self._get_unic_sale_num(), "items": items}
headers = {"Content-Type": "application/json", "X-API-Key": api_key}
endpoint = url.rstrip('/') + '/api/receipt'
_logger.info("POSCP → POST %s payload=%s", endpoint, req)
resp = requests.post(endpoint, json=req, headers=headers, timeout=60)
resp.raise_for_status()
data = resp.json()
_logger.info("POSCP ← receipt API response: %s", data)
webSrv = data.get("WebSrvCmd") or {}
if webSrv.get("HasErr", False):
raise UserError("ECR printing failed")
self.write({'is_fiscalized': True})
self.env.cr.commit()
_logger.info("POSCP → set is_fiscalized=True for id=%s", self.id)
return data
def _get_unic_sale_num(self):
self.ensure_one()
dt = fields.Datetime.now()
return f"DY{dt.strftime('%y%m%d')}-OP{str(0).zfill(2)}-{str(self.id or 0).zfill(7)}"
Thanks in advance for any pointers or best practices to handle both cases!