Skip to content

Partner invoice upload flow

When an authorised partner (today: optician on Marmot, supplier = LPL) uploads a PDF invoice for a Saleor fulfillment, the request goes through 3 explicit phases, each backed by a separate "brick". The orchestrator (process_partner_invoice.py) wires them together and decides which persistence path to take based on the check result.

                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚  Controller : POST .../invoice         β”‚
                β”‚  admin_order.py                         β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                     ↓
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚  ORCHESTRATOR                        β”‚
                  β”‚  process_partner_invoice_upload      β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                     β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        ↓                            ↓                            ↓

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ BRICK 1         β”‚       β”‚ BRICK 2         β”‚         β”‚ BRICK 3          β”‚
  β”‚ Parse +         β”‚  β†’    β”‚ Automated       β”‚   β†’     β”‚ Persist          β”‚
  β”‚ optician-facing β”‚       β”‚ checks          β”‚         β”‚ (success / fail) β”‚
  β”‚ validation      β”‚       β”‚ (silent, ops)   β”‚         β”‚                  β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   raises ValueError         returns list[str]            writes Saleor
   on user-fixable           on rule failures              metadata
   problem                                                 β†’ drives UI badge

Brick 1 β€” Parse + opticien-facing validation

Module : partner_invoice_upload.py

Purpose : turn the raw PDF into a structured ParsedPartnerInvoice and reject inputs the optician can fix themselves (wrong file, wrong commande, etc.).

Country-agnostic : the parser dispatch + the validation rules apply to every country that uses this flow. The country adapter is used only to resolve the buyer's name via resolve_user_name_from_user_id.

Dispatch : parser selection is driven by warehouse.slug through PARSER_REGISTRY. Adding a new supplier means registering a new parser function in that mapping (and nothing else in this brick).

Failure mode : ValueError β†’ the controller surfaces it as a 400 with the error message. The optician sees the error in the dashboard and retries with a fixed file.

Brick 2 β€” Automated checks (silent, ops-facing)

Modules : - shop aggregator : glasses_invoice_post_upload_checks.py - country-specific stub : fr/.../glasses_invoice_post_upload_checks.py

Purpose : run rules that should not block the upload from the optician's perspective but should prevent the system from creating an InsuranceDocument on suspicious data.

Reached via : the shop aggregator delegates to shop_adapter.run_country_specific_invoice_automated_checks, which routes to the appropriate country's check module. The base adapter ships a no-op default (return []), so a country opts in by overriding the method.

Failure mode : returns a list[str] of human-readable error messages. Empty list β†’ success branch. Non-empty β†’ failure branch (see Brick 3). No exception is raised β€” the optician's UI flow never stops here.

Adding a new check rule : append to the country-specific module's run_*_invoice_automated_checks function. The specific rules are documented in the source, intentionally not duplicated here β€” they evolve per business need.

Brick 3 β€” Persistence

Module : process_partner_invoice.py (_persist_successful_upload / _persist_failed_upload)

The orchestrator branches based on the aggregated check errors :

Success path (no errors)

  • Calls shop_adapter.create_insurance_document_from_parsed_partner_invoice, which delegates to the country's claim engine to create a real InsuranceDocument with care acts.
  • Writes invoice-document-id + invoice-file-name on the Saleor fulfillment metadata.
  • Posts a success Saleor order note.

Failure path (β‰₯ 1 error)

  • Calls the shop-level store_failed_partner_invoice_pdf, which uploads the rejected PDF as a generic Document (with the country-specific DocumentType from shop_adapter.get_lab_invoice_document_type) β€” it stays out of the claim engine.
  • Writes failed-invoice-document-id + failed-invoice-file-name on the fulfillment metadata.
  • Posts a failure Saleor order note listing every error.
  • Pings ops on Slack so a human can review the PDF.

The two metadata key sets are mutually exclusive : the presence of invoice-document-id is the "checks passed" signal, the presence of failed-invoice-document-id is the "checks failed" signal. No separate flag needed.

How the frontend uses the metadata

The Marmot optician dashboard reads the fulfillment metadata to render the tΓ©lΓ©TT status :

Metadata key present Badge Meaning
invoice-document-id πŸ“‘ PrΓͺt pour tΓ©lΓ©transmission Care acts created, ready for the claim engine.
failed-invoice-document-id πŸ›‘ En attente de validation de la facture Rejected PDF stored, ops needs to review.
neither (no badge) No invoice uploaded yet.