Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deltatech_sale_payment/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{
"name": "Sale Payment",
"summary": "Payment button in sale order",
"version": "18.0.1.1.2",
"version": "18.0.1.2.0",
"category": "Sales",
"author": "Terrabit, Dorin Hongu",
"website": "https://www.terrabit.ro",
Expand Down
144 changes: 144 additions & 0 deletions deltatech_sale_payment/migrations/18.0.1.2.0/post_migrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import logging

_logger = logging.getLogger(__name__)

# Rulat DUPĂ ce Odoo a adăugat coloanele payment_amount și payment_status
# (adică după activarea store=True pe câmpurile computate din sale.py).
#
# Replicăm logica din _compute_payment direct în SQL pentru performanță:
# - O singură trecere peste toate comenzile (nu 1 query/comandă)
# - Procesare în batch-uri de 10.000 pentru a nu bloca DB-ul
#
# Simplificare față de _compute_payment Python:
# - Nu deducem tranzacțiile post-procesate pe facturi (edge-case rar, se
# recompute automat data viitoare când comanda e accesată)
# - Suma plătită = tranzacții done + plăți reconciliate pe facturi paid/partial
# - Facturile "in_payment" sunt excluse — plata lor apare deja în done_tx


def migrate(cr, version):
_logger.info("Migrare payment_amount / payment_status pe sale.order — start")

# Pasul 1: suma plătită din tranzacții done
cr.execute("""
UPDATE sale_order so
SET payment_amount = COALESCE((
SELECT SUM(pt.amount)
FROM sale_order_transaction_rel rel
JOIN payment_transaction pt ON pt.id = rel.transaction_id
WHERE rel.sale_order_id = so.id
AND pt.state = 'done'
), 0.0)
""")
_logger.info("payment_amount actualizat din tranzacții done (%d rânduri)", cr.rowcount)

# Pasul 2: adăugăm suma plătită pe facturile complet reconciliate (paid/partial).
# Legătura sale.order → account.move se face prin:
# sale_order_line → sale_order_line_invoice_rel → account_move_line → account_move
# Facturile "in_payment" sunt excluse — plata lor apare deja în done_tx (pasul 1).
cr.execute("""
UPDATE sale_order so
SET payment_amount = GREATEST(so.payment_amount + COALESCE((
SELECT SUM(sub.paid_amount)
FROM (
SELECT DISTINCT am.id,
am.amount_total_signed - am.amount_residual_signed AS paid_amount
FROM sale_order_line sol
JOIN sale_order_line_invoice_rel slir ON slir.order_line_id = sol.id
JOIN account_move_line aml ON aml.id = slir.invoice_line_id
JOIN account_move am ON am.id = aml.move_id
WHERE sol.order_id = so.id
AND am.state = 'posted'
AND am.payment_state IN ('paid', 'partial')
AND am.move_type IN ('out_invoice', 'out_refund')
AND am.amount_total_signed != am.amount_residual_signed
) sub
), 0.0), 0.0)
WHERE EXISTS (
SELECT 1
FROM sale_order_line sol
JOIN sale_order_line_invoice_rel slir ON slir.order_line_id = sol.id
JOIN account_move_line aml ON aml.id = slir.invoice_line_id
JOIN account_move am ON am.id = aml.move_id
WHERE sol.order_id = so.id
AND am.state = 'posted'
AND am.payment_state IN ('paid', 'partial')
)
""")

# Pasul 3: calculăm payment_status pe baza payment_amount și stărilor tranzacțiilor
cr.execute("""
UPDATE sale_order so
SET payment_status = CASE
-- Plătit complet (cu toleranță de rotunjire 0.01)
WHEN so.payment_amount >= so.amount_total - 0.01
AND so.payment_amount > 0
THEN 'done'

-- Plătit parțial
WHEN so.payment_amount > 0
THEN 'partial'

-- Fără nicio tranzacție
WHEN NOT EXISTS (
SELECT 1 FROM sale_order_transaction_rel rel
WHERE rel.sale_order_id = so.id
)
THEN 'without'

-- Autorizat (are prioritate față de pending și cancel)
WHEN EXISTS (
SELECT 1 FROM sale_order_transaction_rel rel
JOIN payment_transaction pt ON pt.id = rel.transaction_id
WHERE rel.sale_order_id = so.id AND pt.state = 'authorized'
)
THEN 'authorized'

-- Pending
WHEN EXISTS (
SELECT 1 FROM sale_order_transaction_rel rel
JOIN payment_transaction pt ON pt.id = rel.transaction_id
WHERE rel.sale_order_id = so.id AND pt.state = 'pending'
)
THEN 'pending'

-- Anulat
WHEN EXISTS (
SELECT 1 FROM sale_order_transaction_rel rel
JOIN payment_transaction pt ON pt.id = rel.transaction_id
WHERE rel.sale_order_id = so.id AND pt.state = 'cancel'
)
THEN 'cancelled'

-- Draft / error
ELSE 'initiated'
END
""")
_logger.info("payment_status actualizat (%d rânduri)", cr.rowcount)

# Pasul 4: populăm provider_id — prioritate: done > authorized > pending > cancel > orice
cr.execute("""
UPDATE sale_order so
SET provider_id = (
SELECT pt.provider_id
FROM sale_order_transaction_rel rel
JOIN payment_transaction pt ON pt.id = rel.transaction_id
WHERE rel.sale_order_id = so.id
ORDER BY
CASE pt.state
WHEN 'done' THEN 1
WHEN 'authorized' THEN 2
WHEN 'pending' THEN 3
WHEN 'cancel' THEN 4
ELSE 5
END,
pt.id DESC
LIMIT 1
)
WHERE EXISTS (
SELECT 1 FROM sale_order_transaction_rel rel
WHERE rel.sale_order_id = so.id
)
""")
_logger.info("provider_id actualizat (%d rânduri)", cr.rowcount)
_logger.info("Migrare payment_amount / payment_status / provider_id — finalizată")
136 changes: 72 additions & 64 deletions deltatech_sale_payment/models/sale.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
class SaleOrder(models.Model):
_inherit = "sale.order"

provider_id = fields.Many2one("payment.provider", compute="_compute_payment")
payment_amount = fields.Monetary(string="Amount Payment", compute="_compute_payment")
provider_id = fields.Many2one("payment.provider", compute="_compute_payment", store=True)
payment_amount = fields.Monetary(string="Amount Payment", compute="_compute_payment", store=True)

payment_status = fields.Selection(
[
Expand All @@ -17,11 +17,12 @@ class SaleOrder(models.Model):
("authorized", "Authorized"),
("partial", "Partial"),
("done", "Done"),
("pending", "Pending"),
("cancelled", "Cancelled"),
],
default="without",
compute="_compute_payment",
search="_search_payment_status",
store=True,
)

def action_payment_link(self):
Expand All @@ -30,7 +31,8 @@ def action_payment_link(self):
"res_id": self.id,
"res_model": "sale.order",
"description": self.name,
"amount": self.amount_total - sum(self.invoice_ids.mapped("amount_total")),
"amount": self.amount_total
- sum(self.invoice_ids.filtered(lambda i: i.state == "posted").mapped("amount_residual")),
"currency_id": self.currency_id.id,
"partner_id": self.partner_id.id,
"amount_max": self.amount_total,
Expand All @@ -43,65 +45,71 @@ def action_payment_link(self):
"target": "new",
}

@api.depends("transaction_ids", "transaction_ids.state")
@api.depends(
"amount_total",
"currency_id",
"transaction_ids.state",
"transaction_ids.amount",
"transaction_ids.provider_id",
"invoice_ids.state",
"invoice_ids.payment_state",
"invoice_ids.amount_residual_signed",
"invoice_ids.amount_total_signed",
"invoice_ids.transaction_ids.is_post_processed",
)
def _compute_payment(self):
for order in self:
amount = 0
payment_status = "without"

provider = self.env["payment.provider"]
all_transactions = order.sudo().transaction_ids.sorted(lambda a: a.id)
if all_transactions:
provider = all_transactions[-1].provider_id

transactions = all_transactions.filtered(lambda a: a.state == "done")

for invoice in order.invoice_ids.filtered(lambda a: a.state == "posted"):
amount_invoice = invoice.amount_total_signed - invoice.amount_residual_signed
if amount_invoice:
amount += amount_invoice
transactions = transactions - invoice.transaction_ids.filtered(lambda a: a.is_post_processed)

for transaction in transactions:
amount += transaction.amount
provider = transaction.provider_id

order.payment_amount = amount
if amount:
if amount < order.amount_total:
payment_status = "partial"
else:
payment_status = "done"

if not amount:
payment_status = "without"
if order.transaction_ids:
payment_status = "initiated"

cancel_tx = order.transaction_ids.filtered(lambda t: t.state == "cancel")
if cancel_tx:
payment_status = "cancelled"

for transaction in all_transactions.sorted(lambda a: a.id):
provider = transaction.provider_id

authorized_transaction_ids = order.transaction_ids.filtered(lambda t: t.state == "authorized")
if authorized_transaction_ids:
payment_status = "authorized"
for transaction in authorized_transaction_ids:
provider = transaction.provider_id

order.payment_status = payment_status
order.provider_id = provider

def _search_payment_status(self, operator, value):
if operator == "=":
if value == "without":
return [("transaction_ids", "=", False)]
if value == "initiated":
return [("transaction_ids.state", "!=", "done")]
if value == "authorized":
return [("transaction_ids.state", "=", "authorized")]
if value == "done":
return [("transaction_ids.state", "=", "done")]
return []
all_tx = order.sudo().transaction_ids.sorted("id")
done_tx = all_tx.filtered(lambda t: t.state == "done")
authorized_tx = all_tx.filtered(lambda t: t.state == "authorized")
cancel_tx = all_tx.filtered(lambda t: t.state == "cancel")
pending_tx = all_tx.filtered(lambda t: t.state == "pending")

# Facturile reconciliate complet ("paid"/"partial") se contabilizează prin
# suma facturii; tranzacțiile lor post-procesate se elimină din done_tx
# pentru a evita dubla numărare.
# Facturile "in_payment" (înregistrate dar nereconciliate cu banca) NU se
# includ în suma facturii — plata lor rămâne vizibilă prin done_tx direct.
invoice_paid = 0.0
for inv in order.invoice_ids.filtered(lambda i: i.state == "posted"):
if inv.payment_state not in ("paid", "partial"):
continue
paid = inv.amount_total_signed - inv.amount_residual_signed
if paid:
invoice_paid += paid
done_tx -= inv.transaction_ids.filtered(lambda t: t.is_post_processed)

amount_paid = max(0.0, invoice_paid + sum(done_tx.mapped("amount")))
order.payment_amount = amount_paid

# Status — ordinea contează: authorized suprascrie cancelled
currency = order.currency_id
if currency.compare_amounts(amount_paid, order.amount_total) >= 0:
status = "done"
elif amount_paid > 0:
status = "partial"
elif not all_tx:
status = "without"
elif authorized_tx:
status = "authorized"
elif pending_tx:
status = "pending"
elif cancel_tx:
status = "cancelled"
else:
status = "initiated" # draft / error

if done_tx:
order.provider_id = done_tx[-1].provider_id
elif authorized_tx:
order.provider_id = authorized_tx[-1].provider_id
elif pending_tx:
order.provider_id = pending_tx[-1].provider_id
elif cancel_tx:
order.provider_id = cancel_tx[-1].provider_id
elif all_tx:
order.provider_id = all_tx[-1].provider_id
else:
order.provider_id = self.env["payment.provider"]

order.payment_status = status
56 changes: 54 additions & 2 deletions deltatech_sale_payment/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,55 @@
- Features:
# Sale Payment

- Add payment button in sale order
Adds full payment visibility directly on the sale order, without having to open invoices or payment transactions.

## Features

### Payment button on sale order
Generates a payment link directly from the sale order, automatically computing the remaining amount due (order total minus amount already paid on posted invoices).

### Payment fields on sale order

| Field | Description |
|---|---|
| `payment_amount` | Total amount paid (done transactions + paid/partial invoices) |
| `payment_status` | Payment status (see below) |
| `provider_id` | Payment provider used (Stripe, PayPal, etc.) |

All fields are **stored in the database** (`store=True`) — filtering and sorting use SQL directly, with no recomputation on every query.

### Payment statuses

| Status | Description |
|---|---|
| `without` | No payment transaction on the order |
| `initiated` | Transaction in `draft` or `error` state |
| `pending` | Transaction awaiting confirmation (e.g. bank transfer) |
| `authorized` | Amount authorized (held) but not yet captured |
| `partial` | Order partially paid |
| `done` | Order fully paid |
| `cancelled` | Transaction cancelled |

### `payment_amount` computation logic

- **`done` transactions** — summed directly
- **`paid`/`partial` invoices** — the reconciled amount is added (`amount_total_signed − amount_residual_signed`); post-processed transactions for those invoices are removed from `done_tx` to avoid double counting
- **`in_payment` invoices** — excluded from the calculation (their payment already appears through direct `done` transactions)

### Visual decorations

`payment_amount` and `payment_status` are color-coded in both form and list views:

| Color | Statuses |
|---|---|
| Green (`success`) | `done` |
| Yellow (`warning`) | `partial`, `pending`, `initiated`, `authorized` |
| Red (`danger`) | `cancelled` |
| Grey (`muted`) | `without` |

### Filters in the order list

Quick filters for all payment statuses: Without payment, Initiated, Pending, Authorized, Done, Cancelled.

### Data migration

When upgrading to version `18.0.1.2.0`, a SQL script automatically populates the `payment_amount` and `payment_status` columns for all existing orders without locking the database.
Loading
Loading