Skip to content

[ADD] pms_fastapi: folio detail, sale-lines, contacts and invoice con…#45

Draft
jesusVMayor wants to merge 36 commits into
16.0from
16.0_imp_fastapi_invoice
Draft

[ADD] pms_fastapi: folio detail, sale-lines, contacts and invoice con…#45
jesusVMayor wants to merge 36 commits into
16.0from
16.0_imp_fastapi_invoice

Conversation

@jesusVMayor

@jesusVMayor jesusVMayor commented Apr 15, 2026

Copy link
Copy Markdown
Member

Brings the FastAPI billing surface (pms_fastapi) up to the feature set the
front needs to invoice, collect and reconcile from folios, plus cash-session
management and a payments report. The bulk is additive (new endpoints,
schemas and two modules); the few touches to pre-existing endpoints are
backward compatible (see the breaking-changes note at the end).

New endpoints

Folio billing — pms_fastapi/routers/pms_folio.py

  • GET /folios/{id} — billing detail: totals, to-invoice totals, payers and
    reservations (each with its sale-line ids).
  • GET /folios/{id}/sale-lines — billable room/service lines with per-line tax
    breakdown and invoice state. Sections, notes and down payments are excluded.
  • GET /sale-lines/{id} — single billable sale line.
  • GET /folios/{id}/down-payment-invoices — down-payment invoices of the folio.
  • GET /folios/{id}/contacts — unique contacts across the folio, its
    reservations, agencies and guests.
  • POST /folios/invoices — create an invoice from sale lines, optionally
    grouping lines from several folios of the same property, subtracting
    down-payment invoices and posting on creation. Validates duplicates, access,
    line kind, quantities vs pending, single property, customer/contact
    invoicing requirements and the simplified-invoice limit.

Invoicing — pms_fastapi/routers/invoice.py

  • GET /invoices/{id} — full InvoiceDetail for editing.
  • PUT /invoices/{id} — full-replacement edit. Drafts are rewritten in place
    (same id); posted invoices, with confirmRefund=true, generate a refund that
    cancels the original and a new corrected draft linked via replaces.
  • GET /invoices/validate-contact — validate a contact against a property's
    invoicing rules (204 / 422 ProblemDetail).
  • POST /invoices/{id}/reconciliations, DELETE /invoices/{id}/reconciliations/{reconciliation_id},
    GET /invoices/{id}/reconcilable-payments — apply/undo payments and list
    applicable ones. Reconciliation ids are opaque composite strings
    (payment_{id}) so the contract can grow to other document types.

Payments — pms_fastapi/routers/payment.py (new router)

  • GET /payments, GET /payments/{id} — property-scoped listing/detail with
    contact and global-search filters.
  • POST /payments (customer/supplier) and POST /internal-transfers.
  • PATCH /payments/{id} — edit; blocked (409) when the payment, or its
    internal-transfer counterpart, is matched against a bank statement.
  • POST /payments/{id}/cancel — cancel a payment (both legs for transfers);
    409 on fiscal-lock-date or bank-matched payments.
  • GET /payments/{id}/report and POST /payments/report — single-payment
    receipt and bulk report (PDF/XLSX), mirroring the invoices report skeleton.

Cash sessions — pms_fastapi/routers/cash_session.py (new router)

  • POST /cash-sessions, GET /cash-sessions/current,
    GET /cash-sessions/last-closing, POST /cash-sessions/{id}/closing.
  • Backed by account.bank.statement, porting the accounting close from
    pms_api_rest (statement lines, reconciliation, profit/loss difference).
    Cash journals auto-open a session on payment.

New modules

  • roomdoo_payments_exporter — payments report definitions: a shared
    AbstractModel mixin builds the rows so the landscape QWeb PDF and the
    single-sheet XLSX stay in sync. Includes Spanish translations.
  • pms_fastapi_l10n_es — Spanish override of the invoicing contact
    validation: checks AEAT id requirements and the Spanish-passport rule on top
    of the base VAT check.

Notable internals

  • account.move: replaces_invoice_id / replaced_by_invoice_ids to link an
    edited posted invoice to its corrected replacement.
  • folio.sale.line._prepare_invoice_line honours per-line descriptions passed
    through context, so the front can override the invoice line text.
  • Contact validation is extensible: _get_contact_validation_errors /
    _check_fiscal_id return RFC 9457 problem items and are overridden per
    localization.
  • Portal-friendliness: the invoice create/edit/reconcile flows use
    sudo + pms_api_check_access instead of native ACLs, which blocked portal
    users with AccessError.
  • Error responses are localized (i18n .pot + es.po); user-facing
    title/detail are wrapped in _() and resolved via Accept-Language. The
    raises were moved into model helpers so _() resolves the request language.

Cash-session shared state + migration

The open/closed state of a cash session is owned by pms_fastapi via
cash_session_closed. While both APIs coexist behind feature flags a session
may be opened on one API and closed on the other, so:

  • pms_api_rest close now also stamps the flag (temporary bridge,
    _mark_cash_session_closed, guarded on the field's presence and meant to be
    removed with the module). No change to the legacy response.
  • pms_fastapi migration 16.0.1.4.0/post-migrate.py backfills the flag on
    historical cash statements (using is_complete as the closed signal) and
    repairs already-closed rows missing closing date/uid, which previously made
    last-closing return HTTP 500.

New dependencies (pms_fastapi/__manifest__.py, 1.3.0 → 1.4.0)

  • roomdoo_payments_exporter (new, this PR)
  • pms_autoreconcile_folio_payments — note: updating pms_fastapi will install
    it and enable folio-payment auto-reconciliation.

Tests

End-to-end HTTP coverage of the module's own logic: contact validation (base
VAT + Spanish AEAT/passport), folio sale-lines and detail (filtering, state
mapping, payers/contacts dedup), invoice create/edit/reconcile (validation
branches, posted-refund replacement, simplified invoice), payments
create/edit/cancel, journals multi-type filter, and a full open/pay/close cash
flow.

Breaking changes — none for a well-behaved consumer

Nothing is renamed or removed in existing endpoints, paths, params or fields.
Two pre-existing surfaces are extended (backward compatible):

  • GET /invoices — each item in payments[] gains id, paymentAmount,
    paymentAmountCompany, companyCurrency; ref becomes optional. Added
    fields only.
  • GET /journalsjournalType now accepts a repeated value (list) in
    addition to a single value; ?journalType=cash keeps working.

Caveats only for consumers doing strict schema validation (new fields), clients
generated from the OpenAPI spec (journalType type changes → regenerate), or
those string-matching error title/detail (now translated; the offboarding
error was reworded to stop leaking the internal reservation state). The error
type/status contract is unchanged.

@jesusVMayor jesusVMayor marked this pull request as draft May 7, 2026 09:14
@jesusVMayor jesusVMayor force-pushed the 16.0_imp_fastapi_invoice branch 2 times, most recently from 7c79319 to ae2e3ba Compare May 12, 2026 10:57
@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown

Diff Coverage

Diff: origin/16.0...HEAD, staged and unstaged changes

  • pms_api_rest/services/pms_transaction_service.py (16.7%): Missing lines 608,626,766-768
  • pms_fastapi/models/account_bank_statement.py (92.6%): Missing lines 186,198-199,206,209,240,246
  • pms_fastapi/models/account_move.py (100%)
  • pms_fastapi/models/folio_sale_line.py (100%)
  • pms_fastapi/routers/cash_session.py (94.9%): Missing lines 186-187,196-197
  • pms_fastapi/routers/contact.py (75.0%): Missing lines 145-146
  • pms_fastapi/routers/contact_id_number.py (77.8%): Missing lines 157-158,173-174
  • pms_fastapi/routers/invoice.py (83.6%): Missing lines 529,571,580,589,601-603,609-612,619-621,628-629,632-633,643,648,660,668,698,706,746,760,779-780,787,794,822,824,890-891,893,898-899,961-962,971,1085,1155-1156,1184,1194-1195,1225,1243,1290,1303,1306,1317
  • pms_fastapi/routers/login.py (33.3%): Missing lines 30,35-36,47
  • pms_fastapi/routers/payment.py (87.8%): Missing lines 140,288-289,320,323-324,340-342,344-346,351-352,371,376,379-384,387,392,403,408,461,513,517,644-645,663,700,726,740,756
  • pms_fastapi/routers/payment_method.py (85.7%): Missing lines 66
  • pms_fastapi/routers/pms_folio.py (84.2%): Missing lines 200-202,215-217,268-269,278,282-284,287,290-293,346-348,424-425,559,598,618-619,626,633,649,654-655
  • pms_fastapi/routers/pms_reservation.py (100%)
  • pms_fastapi/routers/user.py (60.0%): Missing lines 47,90
  • pms_fastapi/schemas/cash_session.py (100%)
  • pms_fastapi/schemas/contact.py (100%)
  • pms_fastapi/schemas/folio_sale_line.py (97.0%): Missing lines 97,99
  • pms_fastapi/schemas/invoice.py (95.8%): Missing lines 217,223,301,488,496,535,542
  • pms_fastapi/schemas/payment.py (93.5%): Missing lines 150,205,306,319,336,349,361,367,371,379
  • pms_fastapi/schemas/payment_method.py (100%)
  • pms_fastapi/schemas/pms_folio.py (84.9%): Missing lines 181,183,188-191,234,238,240,243-246
  • pms_fastapi/schemas/pms_reservation.py (87.1%): Missing lines 75,79,84,86,91,94-97
  • pms_fastapi/schemas/user.py (100%)
  • pms_fastapi_l10n_es/routers/invoice.py (100%)
  • roomdoo_fastapi/schemas/pms_folio.py (100%)
  • roomdoo_payments_exporter/report/payment_report.py (50.0%): Missing lines 25-27,33,41-43,46,51,67,77-78
  • roomdoo_payments_exporter/report/payment_xlsx.py (34.3%): Missing lines 25-29,33,48,78-79,81,84-86,88-92,94-96,98-99

Summary

  • Total: 1750 lines
  • Missing: 224 lines
  • Coverage: 87%

pms_api_rest/services/pms_transaction_service.py

Lines 604-612

  604                 session_payments, statement, amount, auto_conciliation=True
  605             )
  606             # Force to complete the statement
  607             statement._compute_balance_start()
! 608             self._mark_cash_session_closed(statement)
  609             return {
  610                 "result": True,
  611                 "diff": 0,
  612             }

Lines 622-630

  622                 session_payments, statement, amount, auto_conciliation=True
  623             )
  624             # Force to complete the statement
  625             statement._compute_balance_start()
! 626             self._mark_cash_session_closed(statement)
  627             return {
  628                 "result": True,
  629                 "diff": diff,
  630             }

Lines 762-772

  762         to migrate, the field stays in pms_fastapi. The field only exists when
  763         pms_fastapi is installed (the only case where anyone reads it), so the
  764         write is guarded on its presence.
  765         """
! 766         if "cash_session_closed" not in statement._fields:
! 767             return
! 768         statement.write(
  769             {
  770                 "cash_session_closed": True,
  771                 "cash_session_closed_uid": self.env.user.id,
  772                 "cash_session_closed_date": fields.Datetime.now(),

pms_fastapi/models/account_bank_statement.py

Lines 182-190

  182             "date": fields.Date.today(),
  183         }
  184         if amount < 0.0:
  185             if not journal.loss_account_id:
! 186                 raise UserError(
  187                     _(
  188                         "Please go on the %s journal and define a Loss Account."
  189                         " This account will be used to record cash difference.",
  190                         journal.name,

Lines 194-203

  194                 "Cash difference observed during the counting (Loss) - closing"
  195             )
  196             st_line_vals["counterpart_account_id"] = journal.loss_account_id.id
  197         else:
! 198             if not journal.profit_account_id:
! 199                 raise UserError(
  200                     _(
  201                         "Please go on the %s journal and define a Profit Account."
  202                         " This account will be used to record cash difference.",
  203                         journal.name,

Lines 202-213

  202                         " This account will be used to record cash difference.",
  203                         journal.name,
  204                     )
  205                 )
! 206             st_line_vals["payment_ref"] = _(
  207                 "Cash difference observed during the counting (Profit) - closing"
  208             )
! 209             st_line_vals["counterpart_account_id"] = journal.profit_account_id.id
  210         self.env["account.bank.statement.line"].sudo().create(st_line_vals)
  211 
  212     def _pms_create_statement_lines(self, session_payments, counted_cash):
  213         """Create statement lines for the shift payments and reconcile them."""

Lines 236-244

  236 
  237         # Do not call button post (avoids creating an extra profit/loss line via
  238         # _check_balance_end_real_same_as_computed).
  239         if not self.name:
! 240             self._set_next_sequence()
  241         self.balance_end = counted_cash
  242         lines_of_moves_to_post = self.line_ids.filtered(
  243             lambda line: line.move_id.state != "posted"
  244         )

Lines 242-250

  242         lines_of_moves_to_post = self.line_ids.filtered(
  243             lambda line: line.move_id.state != "posted"
  244         )
  245         if lines_of_moves_to_post:
! 246             lines_of_moves_to_post.move_id._post(soft=False)
  247 
  248         for payment, statement_line in payment_statement_line_match:
  249             payment_move_line = payment.move_id.line_ids.filtered(
  250                 lambda x, p=payment: x.reconciled is False

pms_fastapi/routers/cash_session.py

Lines 182-191

  182 
  183     def get_current(self, journal_id):
  184         try:
  185             journal = self._resolve_cash_journal(journal_id)
! 186         except _CashSessionProblem as problem:
! 187             return problem.response
  188         statement = self._open_session(journal.id)
  189         if not statement:
  190             return Response(status_code=status.HTTP_204_NO_CONTENT)
  191         return CashSessionSummary.from_account_bank_statement(statement)

Lines 192-201

  192 
  193     def get_last_closing(self, journal_id):
  194         try:
  195             journal = self._resolve_cash_journal(journal_id)
! 196         except _CashSessionProblem as problem:
! 197             return problem.response
  198         statement = (
  199             self.env["account.bank.statement"]
  200             .sudo()
  201             .search(

pms_fastapi/routers/contact.py

Lines 141-150

  141 
  142     def get_or_404(self, contact_id) -> Partner:
  143         try:
  144             return self.get(contact_id)
! 145         except MissingError as err:
! 146             raise HTTPException(
  147                 status_code=404,
  148                 detail=_("contact not found"),
  149             ) from err

pms_fastapi/routers/contact_id_number.py

Lines 153-162

  153     env: AuthenticatedEnv,
  154     contact_id: int,
  155     idNumber_id: int,
  156 ) -> ContactIdNumberSummary:
! 157     helper = env["pms_api_contact.contact_id_number_router.helper"].new()
! 158     id_number = helper._get_id_number_for_contact(contact_id, idNumber_id)
  159     id_number.sudo().set_partner_id_field()
  160     return ContactIdNumberSummary.from_res_partner_id_number(id_number)
  161 

Lines 169-178

  169     env: AuthenticatedEnv,
  170     contact_id: int,
  171     idNumber_id: int,
  172 ):
! 173     helper = env["pms_api_contact.contact_id_number_router.helper"].new()
! 174     id_number = helper._get_id_number_for_contact(contact_id, idNumber_id)
  175     id_number.sudo().unlink()
  176 
  177 
  178 class PmsApiContactIdNumberRouterHelper(models.AbstractModel):

pms_fastapi/routers/invoice.py

Lines 525-533

  525                 media_type="application/problem+json",
  526             )
  527         try:
  528             if invoice.state == "cancel":
! 529                 self._raise_edit_problem(
  530                     409,
  531                     "/errors/invoice-not-editable",
  532                     _("Invoice not editable"),
  533                     _("Cancelled invoices cannot be edited."),

Lines 567-575

  567 
  568     def _edit_resolve_sale_lines(self, payload):
  569         line_ids = [fl.id for fl in payload.folioLines]
  570         if len(line_ids) != len(set(line_ids)):
! 571             self._raise_edit_problem(
  572                 422,
  573                 "/errors/duplicate-sale-lines",
  574                 _("Duplicate sale lines"),
  575                 _("Each folio line can only appear once in the request."),

Lines 576-584

  576             )
  577         sale_lines = self.env["folio.sale.line"].sudo().browse(line_ids).exists()
  578         missing = set(line_ids) - set(sale_lines.ids)
  579         if missing:
! 580             self._raise_edit_problem(
  581                 404,
  582                 "/errors/sale-lines-not-found",
  583                 _("Sale lines not found"),
  584                 _("Some folio lines could not be found."),

Lines 585-593

  585                 missingFolioLineIds=sorted(missing),
  586             )
  587         invalid_kind = sale_lines.filtered(lambda r: r.display_type or r.is_downpayment)
  588         if invalid_kind:
! 589             self._raise_edit_problem(
  590                 422,
  591                 "/errors/invalid-folio-line",
  592                 _("Invalid folio line"),
  593                 _("Sections, notes and down payments cannot be sent as folioLines."),

Lines 597-607

  597 
  598     def _edit_resolve_downpayment_lines(self, payload, sale_lines):
  599         if not payload.downpaymentLines:
  600             return self.env["folio.sale.line"]
! 601         ids = list(set(payload.downpaymentLines))
! 602         if len(ids) != len(payload.downpaymentLines):
! 603             self._raise_edit_problem(
  604                 422,
  605                 "/errors/duplicate-downpayment-lines",
  606                 _("Duplicate down-payment invoices"),
  607                 _("Each down-payment invoice can only appear once in the request."),

Lines 605-616

  605                 "/errors/duplicate-downpayment-lines",
  606                 _("Duplicate down-payment invoices"),
  607                 _("Each down-payment invoice can only appear once in the request."),
  608             )
! 609         dp_invoices = self.env["account.move"].sudo().browse(ids).exists()
! 610         missing = set(ids) - set(dp_invoices.ids)
! 611         if missing:
! 612             self._raise_edit_problem(
  613                 404,
  614                 "/errors/downpayment-lines-not-found",
  615                 _("Down-payment invoices not found"),
  616                 _("Some down-payment invoices could not be found."),

Lines 615-625

  615                 _("Down-payment invoices not found"),
  616                 _("Some down-payment invoices could not be found."),
  617                 missingDownpaymentLineIds=sorted(missing),
  618             )
! 619         not_downpayment = dp_invoices.filtered(lambda m: not m._is_downpayment())
! 620         if not_downpayment:
! 621             self._raise_edit_problem(
  622                 422,
  623                 "/errors/invalid-downpayment-line",
  624                 _("Invalid down-payment invoice"),
  625                 _("Only down-payment invoices are accepted as downpaymentLines."),

Lines 624-637

  624                 _("Invalid down-payment invoice"),
  625                 _("Only down-payment invoices are accepted as downpaymentLines."),
  626                 invalidDownpaymentLineIds=not_downpayment.ids,
  627             )
! 628         folio_ids = set(sale_lines.folio_id.ids)
! 629         out_of_scope = dp_invoices.filtered(
  630             lambda m: not set(m.folio_ids.ids) & folio_ids
  631         )
! 632         if out_of_scope:
! 633             self._raise_edit_problem(
  634                 422,
  635                 "/errors/downpayment-line-out-of-scope",
  636                 _("Down-payment invoice out of scope"),
  637                 _(

Lines 639-652

  639                     " folioLines."
  640                 ),
  641                 outOfScopeDownpaymentLineIds=out_of_scope.ids,
  642             )
! 643         return dp_invoices.invoice_line_ids.folio_line_ids.filtered("is_downpayment")
  644 
  645     def _edit_resolve_property(self, sale_lines):
  646         properties = sale_lines.mapped("pms_property_id")
  647         if len(properties) > 1:
! 648             self._raise_edit_problem(
  649                 422,
  650                 "/errors/multiple-properties",
  651                 _("Folio lines from multiple properties"),
  652                 _("All folio lines must belong to the same property."),

Lines 656-664

  656 
  657     def _edit_resolve_partner(self, payload, pms_property):
  658         partner = self.env["res.partner"].sudo().browse(payload.partner).exists()
  659         if not partner:
! 660             self._raise_edit_problem(
  661                 404,
  662                 "/errors/not-found",
  663                 _("Contact not found"),
  664                 _("Customer not found."),

Lines 664-672

  664                 _("Customer not found."),
  665             )
  666         various = self.env.ref("pms.various_pms_partner", raise_if_not_found=False)
  667         if various and partner.id == various.id:
! 668             return partner
  669         errors = self._get_contact_validation_errors(partner, pms_property)
  670         if errors:
  671             self._raise_edit_problem(
  672                 422,

Lines 694-702

  694                     for line in current_invoice_lines
  695                     if sale_line in line.folio_line_ids
  696                 )
  697             if payload_line.quantity > available:
! 698                 qty_errors.append(
  699                     {
  700                         "folioLineId": sale_line.id,
  701                         "requested": payload_line.quantity,
  702                         "pending": available,

Lines 702-710

  702                         "pending": available,
  703                     }
  704                 )
  705         if qty_errors:
! 706             self._raise_edit_problem(
  707                 422,
  708                 "/errors/quantity-exceeds-pending",
  709                 _("Quantity to invoice exceeds pending quantity"),
  710                 _(

Lines 742-750

  742     @staticmethod
  743     def _edit_build_lines_to_invoice(payload, sale_lines, downpayment_lines):
  744         lines_to_invoice = {fl.id: fl.quantity for fl in payload.folioLines}
  745         for dp in downpayment_lines:
! 746             lines_to_invoice[dp.id] = dp.qty_to_invoice or 1
  747         # Include sections and notes referenced by the regular sale lines
  748         # so the resulting invoice keeps its structure.
  749         section_ids = sale_lines.mapped("section_id")
  750         for section in section_ids:

Lines 756-764

  756             lambda r: r.display_type == "line_note"
  757             and r.section_id in sections_in_scope
  758         )
  759         for note in notes:
! 760             lines_to_invoice.setdefault(note.id, 0)
  761         return lines_to_invoice
  762 
  763     def _edit_compute_invoice_vals(
  764         self, payload, sale_lines, downpayment_lines, partner

Lines 775-784

  775                 final=True,
  776                 lines_to_invoice=lines_to_invoice,
  777                 partner_invoice_id=partner.id,
  778             )
! 779         except UserError as e:
! 780             self._raise_edit_problem(
  781                 422,
  782                 "/errors/invoice-creation-failed",
  783                 _("Invoice creation failed"),
  784                 str(e),

Lines 783-791

  783                 _("Invoice creation failed"),
  784                 str(e),
  785             )
  786         if not vals_list:
! 787             self._raise_edit_problem(
  788                 422,
  789                 "/errors/invoice-creation-failed",
  790                 _("Invoice creation failed"),
  791                 _("No invoice could be built from the provided composition."),

Lines 790-798

  790                 _("Invoice creation failed"),
  791                 _("No invoice could be built from the provided composition."),
  792             )
  793         if len(vals_list) > 1:
! 794             self._raise_edit_problem(
  795                 422,
  796                 "/errors/multiple-invoices-created",
  797                 _("Multiple invoices created"),
  798                 _(

Lines 818-828

  818             "narration": payload.narration,
  819             "invoice_line_ids": vals["invoice_line_ids"],
  820         }
  821         if payload.invoiceDate:
! 822             write_vals["invoice_date"] = payload.invoiceDate
  823         if payload.invoiceDateDue:
! 824             write_vals["invoice_date_due"] = payload.invoiceDateDue
  825         invoice.write(write_vals)
  826         return invoice
  827 
  828     def _reverse_invoice_move(self, invoice, reason=None, cancel=True):

Lines 886-903

  886         vals = self._edit_compute_invoice_vals(
  887             payload, sale_lines, downpayment_lines, partner
  888         )
  889         if payload.invoiceDate:
! 890             vals["invoice_date"] = payload.invoiceDate
! 891             vals["date"] = payload.invoiceDate
  892         if payload.invoiceDateDue:
! 893             vals["invoice_date_due"] = payload.invoiceDateDue
  894         vals["narration"] = payload.narration
  895         vals["replaces_invoice_id"] = invoice.id
  896         try:
  897             new_invoice = self.env["account.move"].sudo().create(vals)
! 898         except UserError as e:
! 899             self._raise_edit_problem(
  900                 422,
  901                 "/errors/invoice-creation-failed",
  902                 _("Invoice creation failed"),
  903                 str(e),

Lines 957-966

  957         """Check fiscal identification. Override in localizations.
  958 
  959         Base implementation requires a VAT number.
  960         """
! 961         if not partner.vat:
! 962             return [
  963                 {
  964                     "type": "/errors/missing-fiscal-id",
  965                     "title": _("Missing fiscal identification number"),
  966                     "detail": _(

Lines 967-975

  967                         "The contact has no fiscal identification number" " configured."
  968                     ),
  969                 }
  970             ]
! 971         return []
  972 
  973     # -- Invoice report helpers --
  974 
  975     @staticmethod

Lines 1081-1089

  1081     def _parse_payment_composite_id(composite_id):
  1082         """Return the int payment id from 'payment_{id}', or raise 422."""
  1083         match = _PAYMENT_ID_RE.match(composite_id or "")
  1084         if not match:
! 1085             PmsApiInvoiceRouterHelper._raise_edit_problem(
  1086                 422,
  1087                 "/errors/invalid-reconciliation-id",
  1088                 _("Invalid reconciliation id"),
  1089                 _("Reconciliation id '%s' is malformed. Expected 'payment_{id}'.")

Lines 1151-1160

  1151                     _("Payment not found"),
  1152                     _("Payment %s not found.") % payment_id,
  1153                 )
  1154             if invoice.folio_ids:
! 1155                 if not (payment.folio_ids & invoice.folio_ids):
! 1156                     self._raise_edit_problem(
  1157                         409,
  1158                         "/errors/payment-not-applicable",
  1159                         _("Payment not applicable"),
  1160                         _("Payment does not belong to any folio of this " "invoice."),

Lines 1180-1188

  1180                     _("Payment %s is already reconciled with invoice %s.")
  1181                     % (payment.id, invoice.id),
  1182                 )
  1183             if not invoice_recv or not payment_recv:
! 1184                 self._raise_edit_problem(
  1185                     409,
  1186                     "/errors/payment-not-applicable",
  1187                     _("Payment not applicable"),
  1188                     _("Payment is not in a reconcilable state."),

Lines 1190-1199

  1190             try:
  1191                 (invoice_recv + payment_recv).filtered(
  1192                     lambda line: not line.reconciled
  1193                 ).reconcile()
! 1194             except UserError as e:
! 1195                 self._raise_edit_problem(
  1196                     409,
  1197                     "/errors/payment-not-applicable",
  1198                     _("Payment not applicable"),
  1199                     str(e),

Lines 1221-1229

  1221             )
  1222         try:
  1223             payment_id = self._parse_payment_composite_id(reconciliation_id)
  1224             if invoice.state != "posted":
! 1225                 self._raise_edit_problem(
  1226                     409,
  1227                     "/errors/invoice-not-editable",
  1228                     _("Invoice not editable"),
  1229                     _("The invoice state does not allow undoing reconciliations."),

Lines 1239-1247

  1239                     limit=1,
  1240                 )
  1241             )
  1242             if not payment:
! 1243                 self._raise_edit_problem(
  1244                     404,
  1245                     "/errors/reconciliation-not-found",
  1246                     _("Reconciliation not found"),
  1247                     _("No reconciliation exists between invoice %s and payment %s.")

Lines 1286-1294

  1286             lambda line: line.account_id.account_type
  1287             in ("asset_receivable", "liability_payable")
  1288         )
  1289         if not pay_term_lines:
! 1290             return []
  1291         domain = [
  1292             ("account_id", "in", pay_term_lines.account_id.ids),
  1293             ("parent_state", "=", "posted"),
  1294             ("reconciled", "=", False),

Lines 1299-1310

  1299         ]
  1300         if invoice.is_inbound():
  1301             domain.append(("balance", "<", 0.0))
  1302         else:
! 1303             domain.append(("balance", ">", 0.0))
  1304         folio_ids = invoice.folio_ids.ids
  1305         if folio_ids:
! 1306             domain.append(("folio_ids", "in", folio_ids))
  1307         else:
  1308             domain.append(("partner_id", "=", invoice.commercial_partner_id.id))
  1309         lines = self.env["account.move.line"].sudo().search(domain)
  1310         items = []

Lines 1313-1321

  1313             pay_currency = payment.currency_id or payment.company_id.currency_id
  1314             available_currency = abs(line.amount_residual_currency)
  1315             available_company = abs(line.amount_residual)
  1316             if pay_currency.is_zero(available_currency):
! 1317                 continue
  1318             items.append(
  1319                 ReconcilablePayment.from_account_payment(
  1320                     payment, available_currency, available_company
  1321                 )

pms_fastapi/routers/login.py

Lines 26-40

  26     """
  27     user_record = env["res.users"].sudo().search([("login", "=", user.username)])
  28 
  29     if not user_record:
! 30         env["pms.fastapi.login.endpoint"]._raise_wrong_credentials()
  31     try:
  32         user_record.with_user(user_record)._check_credentials(
  33             user.password.get_secret_value(), {"interactive": False}
  34         )
! 35     except AccessDenied:
! 36         env["pms.fastapi.login.endpoint"]._raise_wrong_credentials()
  37     return env["pms.fastapi.login.endpoint"]._get_login_response_with_cookies(
  38         user_record
  39     )

Lines 43-51

  43     _name = "pms.fastapi.login.endpoint"
  44     _description = "login endpoint helper"
  45 
  46     def _raise_wrong_credentials(self):
! 47         raise HTTPException(
  48             status_code=401,
  49             detail=_("wrong user/pass"),
  50         )

pms_fastapi/routers/payment.py

Lines 136-144

  136     """Generate and download a payments report in PDF or Excel format.
  137 
  138     Pass either an explicit list of `ids` or filter parameters (the same ones
  139     accepted by GET /payments). They are mutually exclusive."""
! 140     return (
  141         env["pms_api_payment.payment_router.helper"]
  142         .new()
  143         ._generate_report(report_format, filters, ids)
  144     )

Lines 284-293

  284                 _("Payment %s does not exist.") % payment_id,
  285             )
  286         try:
  287             PmsBaseModel.pms_api_check_access(self.env.user, payment)
! 288         except (AccessError, AccessDenied):
! 289             self._problem(
  290                 404,
  291                 "/errors/payment-not-found",
  292                 _("Payment not found"),
  293                 _("Payment %s does not exist.") % payment_id,

Lines 316-328

  316     # -- report (POST /payments/report) --
  317 
  318     @staticmethod
  319     def _has_report_filters(filters):
! 320         return any(v is not None for v in filters.__dict__.values())
  321 
  322     def _resolve_report_payments(self, filters, ids):
! 323         if ids and self._has_report_filters(filters):
! 324             return (
  325                 JSONResponse(
  326                     status_code=400,
  327                     content={
  328                         "type": "/errors/mutually-exclusive-params",

Lines 336-356

  336                     media_type="application/problem+json",
  337                 ),
  338                 None,
  339             )
! 340         if ids:
! 341             domain = [("id", "in", ids)]
! 342             context = None
  343         else:
! 344             domain = filters.to_odoo_domain(self.env)
! 345             context = filters.to_odoo_context(self.env)
! 346         count = (
  347             self.model_adapter.count(domain)
  348             if context is None
  349             else self.model_adapter.count(domain, context=context)
  350         )
! 351         if count > PAYMENT_REPORT_MAX_RECORDS:
! 352             return (
  353                 JSONResponse(
  354                     status_code=400,
  355                     content={
  356                         "type": "/errors/record-limit-exceeded",

Lines 367-396

  367                     media_type="application/problem+json",
  368                 ),
  369                 None,
  370             )
! 371         payments = (
  372             self.model_adapter.search(domain)
  373             if context is None
  374             else self.model_adapter.search(domain, context=context)
  375         )
! 376         return None, payments
  377 
  378     def _generate_report(self, report_format, filters, ids):
! 379         error, payments = self._resolve_report_payments(filters, ids)
! 380         if error is not None:
! 381             return error
! 382         if report_format == ReportFormatEnum.xlsx:
! 383             return self._render_payments_xlsx(payments)
! 384         return self._render_payments_pdf(payments)
  385 
  386     def _render_payments_pdf(self, payments):
! 387         content, _report_type = (
  388             self.env["ir.actions.report"]
  389             .sudo()
  390             ._render_qweb_pdf("roomdoo_payments_exporter.report_payments", payments.ids)
  391         )
! 392         return Response(
  393             content=content,
  394             media_type="application/pdf",
  395             headers={
  396                 "Content-Disposition": 'attachment; filename="payments_report.pdf"',

Lines 399-412

  399 
  400     def _render_payments_xlsx(self, payments):
  401         # `report_xlsx._render_xlsx` forces `.sudo(False)` on the report model,
  402         # so `.sudo()` here would not bypass ACL inside the export.
! 403         content, _report_type = (
  404             self.env["ir.actions.report"]
  405             .with_user(SUPERUSER_ID)
  406             ._render("roomdoo_payments_exporter.payment_report", payments.ids)
  407         )
! 408         return Response(
  409             content=content,
  410             media_type=(
  411                 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  412             ),

Lines 457-465

  457 
  458     def _resolve_partner(self, partner_id):
  459         partner = self.env["res.partner"].sudo().browse(partner_id).exists()
  460         if not partner:
! 461             self._not_found(_("Partner %s does not exist.") % partner_id)
  462         return partner
  463 
  464     def create_payment(self, payload: PaymentInput):
  465         try:

Lines 509-521

  509         """Return the folio for a customer payment context (folio or invoice)."""
  510         if payload.folioId:
  511             folio = self.env["pms.folio"].sudo().browse(payload.folioId).exists()
  512             if not folio:
! 513                 self._not_found(_("Folio %s does not exist.") % payload.folioId)
  514         else:
  515             invoice = self.env["account.move"].sudo().browse(payload.invoiceId).exists()
  516             if not invoice:
! 517                 self._not_found(_("Invoice %s does not exist.") % payload.invoiceId)
  518             folio = invoice.folio_ids[:1]
  519             if not folio:
  520                 self._validation_error(
  521                     _("Invoice %s has no associated folio.") % payload.invoiceId

Lines 640-649

  640             if not payment:
  641                 self._not_found(_("Payment %s does not exist.") % payment_id)
  642             try:
  643                 PmsBaseModel.pms_api_check_access(self.env.user, payment)
! 644             except (AccessError, AccessDenied):
! 645                 self._not_found(_("Payment %s does not exist.") % payment_id)
  646             self._ensure_not_bank_matched(payment)
  647             if payment.is_internal_transfer:
  648                 self._update_internal_transfer(payment, payload)
  649             else:

Lines 659-667

  659         editing it would break the bank reconciliation. Internal transfers move
  660         both legs together, so the counterpart is checked as well."""
  661         legs = payment + payment.paired_internal_transfer_payment_id
  662         if any(legs.mapped("is_matched")):
! 663             self._problem(
  664                 409,
  665                 "/errors/payment-bank-matched",
  666                 _("Payment cannot be modified"),
  667                 _(

Lines 696-704

  696         return vals
  697 
  698     def _ensure_open_cash_session(self, journal):
  699         if journal.type == "cash":
! 700             self.env["account.bank.statement"].sudo()._pms_ensure_open_cash_session(
  701                 journal
  702             )
  703 
  704     def _replace_payment_for_journal_change(self, payment, vals, journal):

Lines 722-730

  722                 vals["payment_method_line_id"] = line.id
  723                 if line.journal_id.id != payment.journal_id.id:
  724                     new_journal = line.journal_id
  725         if not vals:
! 726             return payment
  727         if new_journal:
  728             payment = self._replace_payment_for_journal_change(
  729                 payment, vals, new_journal
  730             )

Lines 736-744

  736         # Re-posting posts the payment's own entry, not the invoice's, so the
  737         # folio<->invoice reconciliation is recomputed explicitly (the same
  738         # entry point do_payment uses).
  739         for move in payment.folio_ids.move_ids:
! 740             move.sudo()._autoreconcile_folio_payments()
  741         return payment
  742 
  743     def _update_internal_transfer(self, payment, payload):
  744         # An internal transfer has two journals (origin + destination), so the

Lines 752-760

  752         counterpart = payment.paired_internal_transfer_payment_id
  753         legs = payment + counterpart
  754         vals = self._common_update_vals(payment, payload)
  755         if not vals:
! 756             return
  757         for leg in legs:
  758             self._ensure_open_cash_session(leg.journal_id)
  759         legs.action_draft()
  760         legs.write(vals)

pms_fastapi/routers/payment_method.py

Lines 62-68

  62             ("allowed_on_pms", "=", True),
  63             ("journal_id", "in", journals.ids),
  64         ]
  65         if payment_type:
! 66             domain.append(("payment_type", "=", payment_type))
  67         return self.env["account.payment.method.line"].sudo().search(domain)

pms_fastapi/routers/pms_folio.py

Lines 196-206

  196     env: AuthenticatedEnv,
  197     line_id: int,
  198 ) -> FolioSaleLine:
  199     """Get a single billable sale line by its id, with invoice state and taxes."""
! 200     helper = env["pms_api_folio.folio_router.helper"].new()
! 201     line = helper.get_sale_line_or_404(line_id)
! 202     return FolioSaleLine.from_folio_sale_line(line)
  203 
  204 
  205 @pms_api_router.get(
  206     "/folios/{folio_id}/down-payment-invoices",

Lines 211-221

  211     env: AuthenticatedEnv,
  212     folio_id: int,
  213 ) -> list[InvoiceSummary]:
  214     """Get the list of down-payment invoices associated with a folio."""
! 215     helper = env["pms_api_folio.folio_router.helper"].new()
! 216     folio = helper.get_or_404(folio_id)
! 217     return helper.get_down_payment_invoices(folio)
  218 
  219 
  220 @pms_api_router.get(
  221     "/folios/{folio_id}/contacts",

Lines 264-273

  264 
  265     def get_or_404(self, folio_id) -> PmsFolio:
  266         try:
  267             return self.get(folio_id)
! 268         except MissingError as err:
! 269             raise HTTPException(
  270                 status_code=404,
  271                 detail=_("folio not found"),
  272             ) from err

Lines 274-297

  274     @property
  275     def sale_line_adapter(self) -> FilteredModelAdapter[FolioSaleLineModel]:
  276         # Same scope as the folio sale-line listing: only billable
  277         # room/service lines, excluding sections, notes and downpayments.
! 278         base_domain = [
  279             ("display_type", "=", False),
  280             ("is_downpayment", "=", False),
  281         ]
! 282         multicompany_domain = self._get_multicompany_rule()
! 283         model_domain = expression.AND([base_domain, multicompany_domain])
! 284         return FilteredModelAdapter[FolioSaleLineModel](self.env, model_domain)
  285 
  286     def get_sale_line(self, line_id) -> FolioSaleLineModel:
! 287         return self.sale_line_adapter.get(line_id)
  288 
  289     def get_sale_line_or_404(self, line_id) -> FolioSaleLineModel:
! 290         try:
! 291             return self.get_sale_line(line_id)
! 292         except MissingError as err:
! 293             raise HTTPException(
  294                 status_code=404,
  295                 detail=_("sale line not found"),
  296             ) from err

Lines 342-352

  342         )
  343         return [FolioSaleLine.from_folio_sale_line(line) for line in lines]
  344 
  345     def get_down_payment_invoices(self, folio) -> list[InvoiceSummary]:
! 346         lines = folio.sale_line_ids.filtered("is_downpayment")
! 347         invoices = lines.invoice_lines.move_id
! 348         return [InvoiceSummary.from_account_move(inv) for inv in invoices]
  349 
  350     def get_contacts(self, folio) -> list[ContactIdImageEmail]:
  351         partners = folio.partner_id
  352         active_reservations = folio.reservation_ids.filtered(

Lines 420-429

  420                 missingSaleLineIds=sorted(missing),
  421             )
  422         try:
  423             PmsBaseModel.pms_api_check_access(self.env.user, sale_lines)
! 424         except (AccessError, AccessDenied):
! 425             self._raise_problem(
  426                 403,
  427                 "/errors/access-denied",
  428                 _("Access denied"),
  429                 _("You are not allowed to access some of the requested sale lines."),

Lines 555-563

  555         self, payload: FolioInvoiceCreate, sale_lines, pms_property
  556     ):
  557         limit = pms_property.max_amount_simplified_invoice
  558         if not limit:
! 559             return
  560         sale_lines_by_id = {sl.id: sl for sl in sale_lines}
  561         invoice_total = sum(
  562             line.quantityToInvoice
  563             * sale_lines_by_id[line.saleLineId].price_reduce_taxinc

Lines 594-602

  594             lambda r: r.display_type == "line_note"
  595             and r.section_id in lines_with_sections
  596         )
  597         for note in line_notes:
! 598             lines_to_invoice.setdefault(note.id, 0)
  599         return lines_to_invoice
  600 
  601     def _build_and_create_invoice(
  602         self, payload: FolioInvoiceCreate, sale_lines, downpayment_lines, partner

Lines 614-623

  614                 partner_invoice_id=partner.id,
  615                 date=payload.invoiceDate,
  616                 final=True,
  617             )
! 618         except UserError as e:
! 619             self._raise_problem(
  620                 422,
  621                 "/errors/invoice-creation-failed",
  622                 _("Invoice creation failed"),
  623                 str(e),

Lines 622-630

  622                 _("Invoice creation failed"),
  623                 str(e),
  624             )
  625         if not invoices:
! 626             self._raise_problem(
  627                 422,
  628                 "/errors/invoice-creation-failed",
  629                 _("Invoice creation failed"),
  630                 _("No invoice could be created from the provided sale lines."),

Lines 629-637

  629                 _("Invoice creation failed"),
  630                 _("No invoice could be created from the provided sale lines."),
  631             )
  632         if len(invoices) > 1:
! 633             self._raise_problem(
  634                 422,
  635                 "/errors/multiple-invoices-created",
  636                 _("Multiple invoices created"),
  637                 _(

Lines 645-659

  645 
  646     @staticmethod
  647     def _override_invoice_due_date(invoice, payload: FolioInvoiceCreate):
  648         if payload.dueDate and payload.dueDate != invoice.invoice_date_due:
! 649             invoice.write({"invoice_date_due": payload.dueDate})
  650 
  651     def _post_invoice(self, invoice):
  652         try:
  653             invoice.action_post()
! 654         except UserError as e:
! 655             self._raise_problem(
  656                 422,
  657                 "/errors/invoice-posting-failed",
  658                 _("Invoice posting failed"),
  659                 str(e),

pms_fastapi/routers/user.py

Lines 43-51

  43 ):
  44     contents = await image.read()
  45     helper = env["pms_api.user_router.helper"].new()
  46     if not contents:
! 47         helper._raise_empty_image()
  48     helper.update_user_image(env.user.id, base64.b64encode(contents))
  49     return User.from_res_users(env.user)
  50 

Lines 86-94

  86                 )
  87         return self.env["res.users"].sudo().browse(user_id).write(vals)
  88 
  89     def _raise_empty_image(self):
! 90         raise HTTPException(
  91             status_code=400,
  92             detail=_("Empty image file uploaded. Use DELETE to remove image."),
  93         )

pms_fastapi/schemas/folio_sale_line.py

Lines 93-103

   93             for entry in invoice_by_move.values()
   94         ]
   95 
   96         if line.invoice_status == "invoiced":
!  97             invoice_state = saleLineInvoiceStateEnum.INVOICED
   98         elif line.invoice_status == "to_invoice" and line.qty_invoiced > 0:
!  99             invoice_state = saleLineInvoiceStateEnum.PARTIALLY_INVOICED
  100         else:
  101             invoice_state = saleLineInvoiceStateEnum.PENDING
  102 
  103         decimal_places = line.currency_id.decimal_places if line.currency_id else 2

pms_fastapi/schemas/invoice.py

Lines 213-221

  213 
  214         if total_currency == widget_currency:
  215             payment_amount_widget = total_amount
  216         else:
! 217             payment_amount_widget = total_currency._convert(
  218                 total_amount, widget_currency, company, total_date
  219             )
  220         if total_currency == company_currency:
  221             payment_amount_company = total_amount

Lines 219-227

  219             )
  220         if total_currency == company_currency:
  221             payment_amount_company = total_amount
  222         else:
! 223             payment_amount_company = total_currency._convert(
  224                 total_amount, company_currency, company, total_date
  225             )
  226 
  227         data = {

Lines 297-305

  297         payment_amount = payment.amount
  298         if pay_currency == company_currency:
  299             payment_amount_company = payment_amount
  300         else:
! 301             payment_amount_company = pay_currency._convert(
  302                 payment_amount,
  303                 company_currency,
  304                 payment.company_id,
  305                 payment.date,

Lines 484-492

  484             data["currency_id"] = CurrencySummary.from_res_currency(
  485                 account_move.currency_id
  486             )
  487         if account_move.invoice_payment_term_id:
! 488             data["paymentTerm"] = PaymentTermId.from_account_payment_term(
  489                 account_move.invoice_payment_term_id
  490             )
  491         if account_move.journal_id:
  492             data["journal"] = JournalSummary.from_account_journal(

Lines 492-500

  492             data["journal"] = JournalSummary.from_account_journal(
  493                 account_move.journal_id
  494             )
  495         if account_move.has_overdue_payments:
! 496             data["paymentState"] = InvoicePaymentStateEnum.overdue
  497         else:
  498             data["paymentState"] = ODOO_PAYMENT_STATE_MAP.get(
  499                 account_move.payment_state, InvoicePaymentStateEnum.not_paid
  500             )

Lines 531-539

  531                 account_move.reversed_entry_id
  532             )
  533         refund = account_move.reversal_move_id[:1]
  534         if refund:
! 535             data["refundedBy"] = InvoiceRef.from_account_move(refund)
  536         if account_move.replaces_invoice_id:
  537             data["replaces"] = InvoiceRef.from_account_move(
  538                 account_move.replaces_invoice_id
  539             )

Lines 538-546

  538                 account_move.replaces_invoice_id
  539             )
  540         replacement = account_move.replaced_by_invoice_ids[:1]
  541         if replacement:
! 542             data["replacedBy"] = InvoiceRef.from_account_move(replacement)
  543         return cls(**data)
  544 
  545     @staticmethod
  546     def _invoiced_quantity(folio_line, invoice_lines):

pms_fastapi/schemas/payment.py

Lines 146-154

  146     move_type: ReconciledInvoiceTypeEnum = Field(alias="invoiceType")
  147 
  148     @classmethod
  149     def from_account_move(cls, move):
! 150         return cls(
  151             id=move.id,
  152             name=move.name or "",
  153             move_type=ODOO_MOVE_TYPE_TO_RECONCILED_ENUM[move.move_type],
  154         )

Lines 201-209

  201         reconciled_invoices = (
  202             payment.reconciled_invoice_ids | payment.reconciled_bill_ids
  203         )
  204         if reconciled_invoices:
! 205             data["invoices"] = [
  206                 ReconciledInvoice.from_account_move(move)
  207                 for move in reconciled_invoices
  208             ]
  209         return cls(**data)

Lines 302-310

  302         if self.pmsPropertyId:
  303             properties = env["pms.property"].sudo().browse(self.pmsPropertyId)
  304             PmsBaseModel.pms_api_check_access(env.user, properties)
  305         else:
! 306             properties = env.user.pms_property_id or env.user.pms_property_ids
  307         journals = env["account.journal"]
  308         for prop in properties:
  309             journals |= prop._get_payment_methods(automatic_included=True).journal_id
  310         # Avoid exposing generic company journals not tied to any property.

Lines 315-323

  315         type_domains = []
  316         for pt in self.paymentType:
  317             transaction_type = ENUM_TO_TRANSACTION_TYPE[pt]
  318             if transaction_type == "internal_transfer":
! 319                 type_domains.append([("is_internal_transfer", "=", True)])
  320             else:
  321                 payment_type, partner_type = _TRANSACTION_TYPE_TO_FIELDS[
  322                     transaction_type
  323                 ]

Lines 332-340

  332 
  333     def to_odoo_domain(self, env: api.Environment) -> list:
  334         domain = [("journal_id", "in", self._property_journal_ids(env))]
  335         if self.globalSearch:
! 336             domain = expression.AND(
  337                 [
  338                     domain,
  339                     [
  340                         "|",

Lines 345-353

  345                     ],
  346                 ]
  347             )
  348         if self.dateFrom and self.dateTo:
! 349             domain = expression.AND(
  350                 [
  351                     domain,
  352                     [
  353                         ("date", ">=", self.dateFrom),

Lines 357-365

  357             )
  358         if self.paymentType:
  359             domain = expression.AND([domain, self._payment_type_domain()])
  360         if self.paymentMethod:
! 361             domain = expression.AND(
  362                 [domain, [("payment_method_line_id", "in", self.paymentMethod)]]
  363             )
  364         if self.reference:
  365             domain = expression.AND([domain, [("ref", "ilike", self.reference)]])

Lines 363-375

  363             )
  364         if self.reference:
  365             domain = expression.AND([domain, [("ref", "ilike", self.reference)]])
  366         if self.contact:
! 367             domain = expression.AND(
  368                 [domain, [("partner_id.display_name", "ilike", self.contact)]]
  369             )
  370         if self.createdBy:
! 371             domain = expression.AND(
  372                 [domain, [("create_uid.name", "ilike", self.createdBy)]]
  373             )
  374         if self.amountEq is not None:
  375             domain = expression.AND([domain, [("amount", "=", self.amountEq)]])

Lines 375-381

  375             domain = expression.AND([domain, [("amount", "=", self.amountEq)]])
  376         if self.amountGt is not None:
  377             domain = expression.AND([domain, [("amount", ">", self.amountGt)]])
  378         if self.amountLt is not None:
! 379             domain = expression.AND([domain, [("amount", "<", self.amountLt)]])
  380         return domain

pms_fastapi/schemas/pms_folio.py

Lines 177-195

  177             RoomId.from_pms_room(room)
  178             for room in reservation.reservation_line_ids.mapped("room_id")
  179         ]
  180         if reservation.overbooking and reservation.state != "cancel":
! 181             filtered_data["state"] = reservationStateEnum.OVERBOOKING
  182         elif reservation.state == "draft":
! 183             filtered_data["state"] = reservationStateEnum.DRAFT
  184         elif reservation.state == "cancel":
  185             filtered_data["state"] = reservationStateEnum.CANCELLED
  186         elif reservation.state in ("confirm", "arrival_delayed"):
  187             filtered_data["state"] = reservationStateEnum.ARRIVAL
! 188         elif reservation.state in ("onboard", "departure_delayed"):
! 189             filtered_data["state"] = reservationStateEnum.IN_HOUSE
! 190         elif reservation.state == "done":
! 191             filtered_data["state"] = reservationStateEnum.COMPLETED
  192         filtered_data["saleLineIds"] = reservation.sale_line_ids.ids
  193         return cls(**filtered_data)
  194 

Lines 230-250

  230             filtered_data["currency"] = CurrencySummary.from_res_currency(
  231                 folio.currency_id
  232             )
  233         if folio.partner_id and folio.partner_id.nationality_id:
! 234             filtered_data["nationality"] = CountrySummary.from_res_country(
  235                 folio.partner_id.nationality_id
  236             )
  237         if any(move.has_overdue_payments for move in folio.move_ids):
! 238             filtered_data["paymentState"] = folioPaymentStateEnum.OVERDUE
  239         elif folio.payment_state in ("paid", "nothing_to_pay"):
! 240             filtered_data["paymentState"] = folioPaymentStateEnum.PAID
  241         elif folio.payment_state == "not_paid":
  242             filtered_data["paymentState"] = folioPaymentStateEnum.NOT_PAID
! 243         elif folio.payment_state == "partial":
! 244             filtered_data["paymentState"] = folioPaymentStateEnum.PARTIALLY_PAID
! 245         elif folio.payment_state == "overpayment":
! 246             filtered_data["paymentState"] = folioPaymentStateEnum.OVERPAID
  247         if folio.invoice_status in ("to_invoice", "to_confirm"):
  248             filtered_data["invoiceState"] = invoiceStateEnum.TO_INVOICE
  249         else:
  250             filtered_data["invoiceState"] = invoiceStateEnum.INVOICED

pms_fastapi/schemas/pms_reservation.py

Lines 71-101

   71             filtered_data["saleChannel"] = SaleChannelDetail.from_pms_sale_channel(
   72                 reservation.sale_channel_origin_id
   73             )
   74         if reservation.agency_id:
!  75             filtered_data["agency"] = AgencyIdImage.from_res_partner(
   76                 reservation.agency_id
   77             )
   78         if reservation.overbooking and reservation.state != "cancel":
!  79             filtered_data["state"] = reservationStateEnum.OVERBOOKING
   80         elif (
   81             reservation.is_reselling
   82             or any(reservation.reservation_line_ids.mapped("is_reselling"))
   83         ) and reservation.state != "cancel":
!  84             filtered_data["state"] = reservationStateEnum.RESELLING
   85         elif reservation.state == "draft":
!  86             filtered_data["state"] = reservationStateEnum.DRAFT
   87         elif reservation.state == "cancel":
   88             if reservation.cancelled_reason == "noshow":
   89                 filtered_data["state"] = reservationStateEnum.NO_SHOW
   90             else:
!  91                 filtered_data["state"] = reservationStateEnum.CANCELLED
   92         elif reservation.state in ("confirm", "arrival_delayed"):
   93             filtered_data["state"] = reservationStateEnum.ARRIVAL
!  94         elif reservation.state in ("onboard", "departure_delayed"):
!  95             filtered_data["state"] = reservationStateEnum.IN_HOUSE
!  96         elif reservation.state == "done":
!  97             filtered_data["state"] = reservationStateEnum.COMPLETED
   98 
   99         checkin_partners = reservation.checkin_partner_ids
  100         filtered_data["incompleteCheckinsCount"] = len(
  101             checkin_partners.filtered(lambda c: c.state in ("dummy", "draft"))

roomdoo_payments_exporter/report/payment_report.py

Lines 21-31

  21         Mirrors the API contract (PaymentSummary) without depending on the
  22         `pms_api_transaction_type` computed field, so the report module stays
  23         decoupled from the API layer.
  24         """
! 25         if payment.is_internal_transfer:
! 26             return _("Internal transfer")
! 27         labels = {
  28             ("inbound", "customer"): _("Customer payment"),
  29             ("outbound", "customer"): _("Customer refund"),
  30             ("outbound", "supplier"): _("Supplier payment"),
  31             ("inbound", "supplier"): _("Supplier refund"),

Lines 29-37

  29             ("outbound", "customer"): _("Customer refund"),
  30             ("outbound", "supplier"): _("Supplier payment"),
  31             ("inbound", "supplier"): _("Supplier refund"),
  32         }
! 33         return labels.get((payment.payment_type, payment.partner_type), "")
  34 
  35     def _payment_report_rows(self, payments):
  36         """Flatten payments into the rows shown by the PDF / XLSX report.

Lines 37-55

  37 
  38         Single source of truth shared by both renderers. New columns are added
  39         here so PDF and Excel stay in sync.
  40         """
! 41         rows = []
! 42         for payment in payments:
! 43             currency = payment.currency_id or payment.company_id.currency_id
  44             # Internal transfers carry the company partner internally; the
  45             # report shows no contact for them, replicating the listing.
! 46             partner = (
  47                 payment.partner_id
  48                 if payment.partner_id and not payment.is_internal_transfer
  49                 else False
  50             )
! 51             rows.append(
  52                 {
  53                     "name": payment.name or "",
  54                     "date": payment.date,
  55                     "type_label": self._payment_type_label(payment),

Lines 63-71

  63                     "currency": currency,
  64                     "currency_name": currency.name or "",
  65                 }
  66             )
! 67         return rows
  68 
  69 
  70 class ReportPayments(models.AbstractModel):
  71     _name = "report.roomdoo_payments_exporter.report_payments"

Lines 73-82

  73     _description = "Payments PDF Report"
  74 
  75     @api.model
  76     def _get_report_values(self, docids, data=None):
! 77         payments = self.env["account.payment"].browse(docids)
! 78         return {
  79             "doc_ids": docids,
  80             "doc_model": "account.payment",
  81             "docs": payments,
  82             "rows": self._payment_report_rows(payments),

roomdoo_payments_exporter/report/payment_xlsx.py

Lines 21-37

  21     ]
  22     _description = "Payments XLSX Report"
  23 
  24     def _get_date_format(self):
! 25         code = self.env.user.lang or "en_US"
! 26         py_fmt = self.env["res.lang"]._lang_get(code).date_format
! 27         for py, xl in STRFTIME_TO_EXCEL.items():
! 28             py_fmt = py_fmt.replace(py, xl)
! 29         return py_fmt
  30 
  31     def _get_columns(self):
  32         # (label, width, row-key, format-key)
! 33         return [
  34             (_("Number"), 20, "name", "text"),
  35             (_("Date"), 15, "date", "date"),
  36             (_("Type"), 20, "type_label", "text"),
  37             (_("Contact"), 35, "partner_name", "text"),

Lines 44-52

  44             (_("Currency"), 10, "currency_name", "text"),
  45         ]
  46 
  47     def _get_formats(self, workbook):
! 48         return {
  49             "header": workbook.add_format(
  50                 {
  51                     "bold": True,
  52                     "bg_color": "#4472C4",

Lines 74-100

   74         }
   75 
   76     @staticmethod
   77     def _write_cell(sheet, row, col, value, cell_format):
!  78         if value is None or value is False:
!  79             sheet.write_blank(row, col, None, cell_format)
   80         else:
!  81             sheet.write(row, col, value, cell_format)
   82 
   83     def generate_xlsx_report(self, workbook, data, payments):
!  84         rows = self._payment_report_rows(payments)
!  85         fmt = self._get_formats(workbook)
!  86         columns = self._get_columns()
   87 
!  88         sheet = workbook.add_worksheet(_("Payments"))
!  89         for col_idx, (label, width, _key, _fmt_key) in enumerate(columns):
!  90             sheet.write(0, col_idx, label, fmt["header"])
!  91             sheet.set_column(col_idx, col_idx, width)
!  92         sheet.freeze_panes(1, 0)
   93 
!  94         for row_idx, row in enumerate(rows, start=1):
!  95             for col_idx, (_label, _width, key, fmt_key) in enumerate(columns):
!  96                 self._write_cell(sheet, row_idx, col_idx, row.get(key), fmt[fmt_key])
   97 
!  98         if rows:
!  99             sheet.autofilter(0, 0, len(rows), len(columns) - 1)

@jesusVMayor jesusVMayor force-pushed the 16.0_imp_fastapi_invoice branch from eb725f0 to c67130d Compare June 1, 2026 10:35
…tact validation

- GET /folios/{id}: billing detail with totals, reservations and payer list
- GET /folios/{id}/sale-lines: line-level tax breakdown and invoice state
- GET /folios/{id}/contacts: unique contacts across folio, reservations and guests
- GET /invoices/validate-contact: validates fiscal id requirements for a contact
- pms_fastapi_l10n_es: Spanish-specific override checking AEAT id and Spanish-passport rule
The /folios/{id}/sale-lines endpoint must return only room/service
lines. Downpayments are a separate concern and will be exposed through
a dedicated endpoint.
- POST /folios/invoices: create invoice from sale lines across folios of
  the same property, with optional posting and simplified-invoice limit.
- GET /folios/{id}/down-payment-invoices: list down-payment invoices.
- PUT /invoices/{id}: full-replacement edit. Drafts are rewritten
  in-place; posted invoices (with confirmRefund) generate a refund plus
  a new corrected draft linked via replaces_invoice_id.
- folio.sale.line._prepare_invoice_line override to pass per-line
  descriptions through context.
- POST/DELETE /invoices/{id}/reconciliations and GET
  /invoices/{id}/reconcilable-payments to apply/undo payments.
- InvoiceDetail now exposes payments (with currency conversion).

Fixes so the invoice create/edit/reconcile endpoints work for the
portal (frontend) users:

- Use sudo + pms_api_check_access instead of native ACL checks, which
  blocked portal users with AccessError.
- _get_reconciliation_partials matched the wrong counterpart field
  (debit/credit swapped), so DELETE never found the reconciliation.
- _edit_draft_invoice computed the new lines before unlinking the
  old ones, leaving the invoice empty when a line was fully invoiced
  by its own draft.
- InvoiceDetail.from_account_move filtered product lines with
  `not display_type`; in 16.0 they use display_type='product', so
  lines/folioLines/downpaymentLines came back empty.
- Invalidate the invoice cache after (un)reconciling so payment_state
  is fresh in the response.
Cover the module's own logic over HTTP:
- invoice contact validation (base VAT rule + Spanish AEAT/passport rules)
- folio sale-lines: section/note/downpayment filtering, line type,
  invoice/refund aggregation and cancelled-invoice exclusion
- folio detail and contacts: modified-reservation exclusion, cancelled
  and no-show state mapping, payers and contact aggregation/dedup
Cover the invoicing endpoints over HTTP:
- POST /folios/invoices: create draft/posted invoice and validation
  branches (duplicate, not found, section, quantity, customer, contact
  validation, simplified limit, multiple properties)
- PUT /invoices/{id}: draft rewrite, posted refund confirmation and
  replacement, composition and partner validation
- reconciliation endpoints: create/delete and reconcilable-payments,
  with the not-found/not-editable/not-applicable/already-reconciled paths
Configure a simplified sale journal on the test property so the
customerId=null branch of POST /folios/invoices can be exercised.
…nvoicing

Add GET /invoices/{invoice_id} returning InvoiceDetail (404 ProblemDetail
on missing invoice), mirroring the response already served by the PUT and
reconciliation endpoints.

Add downpaymentLines to POST /folios/invoices so down-payment folio lines
can be subtracted, reusing the resolution/validation rules of the edit
flow. Response stays InvoiceSummary.

Cover both with tests.
Payments router + schema + tests, wired into routers/schemas/tests __init__.
Adds UserId schema helper for embedding the creating user.
Front sends down-payment invoice ids in downpaymentLines, but the backend browsed them as folio.sale.line ids. Resolve them as account.move: validate existence, _is_downpayment() and folio scope, then map to their down-payment folio.sale.lines for lines_to_invoice. Invoice read-back returns invoice ids too. Tests build a real down-payment invoice fixture.
Implements the 4 cash-session (cash register shift) endpoints:
- POST /cash-sessions (open)
- GET /cash-sessions/current
- GET /cash-sessions/last-closing
- POST /cash-sessions/{session_id}/closing

Backed by account.bank.statement, porting the accounting close from
pms_api_rest (statement lines, reconciliation, profit/loss difference).
Breakdown computed from account.payment via pms_api_transaction_type.
The hand-built down-payment invoice fixture created move lines with no account and a folio.sale.line with no UoM, which broke account_move_line constraints and qty_invoiced computation once the line was linked to an invoice. Give both the invoice line and the down-payment sale line a product (and UoM), mirroring the down-payment wizard.
POST /payments (customer/supplier) and POST /internal-transfers, returning
PaymentSummary. Reuses pms.folio.do_payment for folio/invoice-context
customer payments (folio derived from invoice.folio_ids) and ports the
internal-transfer creation from pms_api_rest.

Cash journals auto-open a cash session on payment, integrated with the new
cash session model (_pms_ensure_open_cash_session). Internal transfers report
no contact, per contract. Adds payment-creation and an end-to-end
open/pay/close integration test.
Returns the same PaymentSummary as the listing for a single payment id
(deep links, modals). 404 /errors/payment-not-found when the payment does
not exist or is not accessible to the user (property-scoped).
Wrap user-facing error title/detail strings in _() so they are
translatable and returned in the request's Accept-Language. Move the
raises that lived in async endpoint functions into helper model methods
(get_or_404, _raise_wrong_credentials, _get_id_number_for_contact, ...)
so _() resolves the language via self.env.lang.

Add i18n/pms_fastapi.pot and a complete Spanish es.po. Reword the
offboarding error so it no longer leaks the internal reservation state.
Accept a list of journal types in GET /journals (journalType repeatable)
so several types can be filtered at once. Validate internal transfers to
reject journals that are not bank or cash. Add tests for both.
Dedicated query param to filter payments by partner display_name, alongside the existing globalSearch behaviour.
The open/closed state of a cash session is owned by pms_fastapi (the
surviving API) via cash_session_closed. While both APIs coexist behind
feature flags a session may be opened on one and closed on the other, so
this legacy API must also stamp the flag on close; otherwise a session
closed here would still look open to pms_fastapi.

Added as a temporary bridge (_mark_cash_session_closed) guarded on the
field's presence and meant to be removed together with this module.
- Discard the statement on a movement-less close (no payments and the
  count matching the base), matching the legacy API; the endpoint then
  responds 204.
- Track open/closed via cash_session_closed, maintained by both APIs.
- Migration backfills the flag on legacy cash statements (is_complete as
  the closed signal) and repairs already-closed rows missing the closing
  date/uid, which broke last-closing with HTTP 500.
- CashLastClosing falls back to write_date/write_uid when the closing
  metadata is absent, so the endpoint never 500s on incomplete rows.
@jesusVMayor jesusVMayor force-pushed the 16.0_imp_fastapi_invoice branch from de698d8 to 17a17df Compare June 19, 2026 08:11
A payment reconciled against a bank statement cannot be modified
(editing it would break the bank reconciliation). update_payment now
returns 409 if the payment -- or its internal-transfer counterpart -- is
matched.
Download the payment receipt PDF for a single payment, mirroring the
existing GET /invoices/{id}/report. Returns 404 payment-not-found when
the payment does not exist or is out of the portal user's scope.

Extract _resolve_payment_or_404 reused by get() and the new helper.
Cancel a registered payment via action_draft + action_cancel. Returns
404 payment-not-found when missing/out of scope, 409 fiscal-lock-date
when the payment date is in a locked period, and 409 payment-bank-matched
when it is reconciled against a bank statement. Internal transfers cancel
both legs together.
New POST /payments/report endpoint in pms_fastapi: accepts the same
filters as GET /payments or an explicit list of ids (mutually
exclusive), with a record cap, and returns the report in PDF or Excel
via ?format=. Mirrors the invoices accounting-report skeleton so it
shares ACL/property scoping through FilteredModelAdapter.

New roomdoo_payments_exporter module holds the report definitions
(landscape QWeb PDF + single-sheet XLSX) with a shared AbstractModel
mixin building the rows, so PDF and Excel stay in sync. Includes es
translations.
POST /internal-transfers recibe originPaymentMethodId y
destinationPaymentMethodId (account.payment.method.line) en lugar de
origin/destinationJournalId; el diario se deriva de cada linea. Valida
diario distinto, origen outbound y destino inbound, y honra la linea de
destino en la contraparte solo cuando difiere de la que Odoo asigna.

GET /payment-methods admite ?type=inbound|outbound y expone type en el
resumen, para poblar ambos selectores del front.

Tests de creacion actualizados a payment-methods.
Only draft invoices can be deleted (409 otherwise); reuses
FilteredModelAdapter.get for access control and 404 handling.
in_payment se mapea a paid (no a not_paid); folio en to_invoice
se compara con == en lugar de pertenencia a cadena.
GET /payments and /payments/{id} now return an `invoices` list with the
invoices and refunds the payment is reconciled against (id, name,
invoiceType). It is the counterpart of the payments already exposed on
GET /invoices. Covers both customer documents (reconciled_invoice_ids)
and vendor bills/refunds (reconciled_bill_ids).
DELETE /invoices/{id} now cancels a validated invoice via a credit note
when confirmRefund=true, mirroring the PUT flow, and accepts an optional
reason recorded on the credit note ref (same as the account.move.reversal
wizard). Unpaid invoices are fully reversed (credit note reconciled
against the original); paid invoices keep their payments and the credit
note is posted open as a refund owed. Drafts are still deleted in place.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant