[ADD] pms_fastapi: folio detail, sale-lines, contacts and invoice con…#45
[ADD] pms_fastapi: folio detail, sale-lines, contacts and invoice con…#45jesusVMayor wants to merge 36 commits into
Conversation
7c79319 to
ae2e3ba
Compare
Diff CoverageDiff: origin/16.0...HEAD, staged and unstaged changes
Summary
pms_api_rest/services/pms_transaction_service.pyLines 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.pyLines 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 Falsepms_fastapi/routers/cash_session.pyLines 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.pyLines 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 errpms_fastapi/routers/contact_id_number.pyLines 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.pyLines 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, partnerLines 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 @staticmethodLines 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.pyLines 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.pyLines 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.invoiceIdLines 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 theLines 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.pyLines 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.pyLines 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 errLines 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 errLines 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_taxincLines 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, partnerLines 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.pyLines 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.pyLines 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 2pms_fastapi/schemas/invoice.pyLines 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_amountLines 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.pyLines 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 domainpms_fastapi/schemas/pms_folio.pyLines 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.INVOICEDpms_fastapi/schemas/pms_reservation.pyLines 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.pyLines 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.pyLines 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) |
eb725f0 to
c67130d
Compare
…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.
de698d8 to
17a17df
Compare
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.
Brings the FastAPI billing surface (
pms_fastapi) up to the feature set thefront 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.pyGET /folios/{id}— billing detail: totals, to-invoice totals, payers andreservations (each with its sale-line ids).
GET /folios/{id}/sale-lines— billable room/service lines with per-line taxbreakdown 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, itsreservations, agencies and guests.
POST /folios/invoices— create an invoice from sale lines, optionallygrouping 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.pyGET /invoices/{id}— fullInvoiceDetailfor editing.PUT /invoices/{id}— full-replacement edit. Drafts are rewritten in place(same id); posted invoices, with
confirmRefund=true, generate a refund thatcancels the original and a new corrected draft linked via
replaces.GET /invoices/validate-contact— validate a contact against a property'sinvoicing rules (204 / 422 ProblemDetail).
POST /invoices/{id}/reconciliations,DELETE /invoices/{id}/reconciliations/{reconciliation_id},GET /invoices/{id}/reconcilable-payments— apply/undo payments and listapplicable 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 withcontact and global-search filters.
POST /payments(customer/supplier) andPOST /internal-transfers.PATCH /payments/{id}— edit; blocked (409) when the payment, or itsinternal-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}/reportandPOST /payments/report— single-paymentreceipt 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.account.bank.statement, porting the accounting close frompms_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 sharedAbstractModel 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 contactvalidation: 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_idsto link anedited posted invoice to its corrected replacement.
folio.sale.line._prepare_invoice_linehonours per-line descriptions passedthrough context, so the front can override the invoice line text.
_get_contact_validation_errors/_check_fiscal_idreturn RFC 9457 problem items and are overridden perlocalization.
sudo+pms_api_check_accessinstead of native ACLs, which blocked portalusers with
AccessError..pot+es.po); user-facingtitle/detailare wrapped in_()and resolved viaAccept-Language. Theraises 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_fastapiviacash_session_closed. While both APIs coexist behind feature flags a sessionmay be opened on one API and closed on the other, so:
pms_api_restclose now also stamps the flag (temporary bridge,_mark_cash_session_closed, guarded on the field's presence and meant to beremoved with the module). No change to the legacy response.
pms_fastapimigration16.0.1.4.0/post-migrate.pybackfills the flag onhistorical cash statements (using
is_completeas the closed signal) andrepairs already-closed rows missing closing date/uid, which previously made
last-closingreturn 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: updatingpms_fastapiwill installit 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 inpayments[]gainsid,paymentAmount,paymentAmountCompany,companyCurrency;refbecomes optional. Addedfields only.
GET /journals—journalTypenow accepts a repeated value (list) inaddition to a single value;
?journalType=cashkeeps working.Caveats only for consumers doing strict schema validation (new fields), clients
generated from the OpenAPI spec (
journalTypetype changes → regenerate), orthose string-matching error
title/detail(now translated; the offboardingerror was reworded to stop leaking the internal reservation state). The error
type/statuscontract is unchanged.