Skip to content

Architecture

Layers, top to bottom

flowchart TD
    GraphQL[GraphQL Mutations & Queries
app/graphql/**/mutations/] DomainSvc[Domain Services
InvoicePacService, CreditPacService
app/graphql/**/services/] Common[Common Services
PacUtilsService, PacPdfAttachmentService
app/graphql/common/services/] Factory[Factories
PacInvoiceFactory, PacCreditNoteFactory
app/wrappers/pac/factories/] LowLevel[Low-level Client
PacService
app/wrappers/pac/pac_service.py] HTTP[aiohttp ClientSession] Alanube[Alanube REST API] GraphQL --> DomainSvc DomainSvc --> Factory DomainSvc --> Common DomainSvc --> LowLevel Common --> LowLevel Factory --> LowLevel LowLevel --> HTTP HTTP --> Alanube

The rule: only PacService and a few sibling clients in app/wrappers/pac/ make outbound HTTP calls to Alanube. Everything else either calls them or transforms data for them.

Outbound flow: issuing an invoice

sequenceDiagram
    autonumber
    participant Client as GraphQL Client
    participant Mut as invoice_mutations
    participant Svc as InvoicePacService
    participant Fac as PacInvoiceFactory
    participant Pac as PacService
    participant Alanube as Alanube
    participant Worker as taskiq worker

    Client->>Mut: generate_invoice(order_id, mode=DGI)
    Mut->>Svc: generate_pac_invoice(event)
    Svc->>Svc: resolve_ruc_types()
    Svc->>Fac: create_invoice_data(...)
    Fac-->>Svc: PacInvoice payload
    Svc->>Pac: generate_invoice(payload)
    Pac->>Alanube: POST /pan/v1/invoices
    Alanube-->>Pac: { id, legalStatus, cufe }
    Pac-->>Svc: PacInvoiceResponse
    Svc->>Svc: persist document_id, cufe, pac_input, pac_response
    Svc-->>Mut: Invoice
    Mut->>Worker: enqueue pac_status_poll_task
    Mut-->>Client: Invoice

Inbound flow: webhook delivers a status

sequenceDiagram
    autonumber
    participant Alanube
    participant Router as webhooks/router.py
    participant Wh as PacWebhookService
    participant Pac as PacService
    participant Files as FileService

    Alanube->>Router: POST /webhooks/pac/{company_id}
X-Pac-Signature: ... Router->>Router: verify signature Router->>Router: lookup tenant by pac_company_id Router->>Wh: process_document_status(payload) Wh->>Wh: find invoice/credit by document_id or cufe alt PAC_AUTHORIZED or DGI_AUTHORIZED Wh->>Pac: GET /pan/v1/invoices/{id}?documents=pdf Pac-->>Wh: { pdf: } Wh->>Files: download + create_file() end Wh->>Wh: update legal_status Router-->>Alanube: 200 OK

The three paths a PDF can land on a document

flowchart LR
    A[Webhook arrives
preferred] --> Z[(File attached
to entity)] B[Polling task
fallback] --> Z C[Manual mutation
attach_pac_pdf
operator action] --> Z

All three paths share the same final step: call _get_pdf_url_from_pac() → download → FileService.create_file(). This redundancy is intentional — Alanube's webhooks are best-effort.

Multi-tenancy

PAC is strictly per-tenant:

  • Each tenant has its own pac_company_id issued by Alanube (tenant_model.py).
  • Webhook URLs include the company ID in the path (/webhooks/pac/{company_id}) so we can resolve back to the tenant.
  • Every outbound call to Alanube includes ?idCompany={pac_company_id} from the request context.

If context.pac_company_id is missing, the call fails fast with ValueError("PAC company ID is not configured for this tenant").

Dependency injection

We use aioinject. Services declare their dependencies in __init__ and are wired up in app/core/providers/. For PAC code, the relevant providers are typically:

  • Settings — for pac_base_url, pac_api_key, etc.
  • aiohttp.ClientSession — shared HTTP client.
  • ContextWrapper — request context, including pac_company_id and current tenant.
  • FileService — to attach PDFs.