Skip to content

End-to-End Walkthrough: Import of Issued Invoices with Payment Info

Overview

This document provides a comprehensive end-to-end walkthrough of the "Import of issued invoices with payment info" scenario from the ftmo_eval_global.feature file. It traces the complete flow from the BDD test scenario through the Python test framework to the PHP backend API, documenting all classes, methods, and data transformations involved.

High-Level Architecture

graph TB
  subgraph "Test Framework (Python)"
    A[BDD Scenario] --> B[Step Definitions]
    B --> C[ImportConfig]
    C --> D[Controller/HTTP Client]
  end

  subgraph "HTTP Layer"
    D --> E[POST /import/document/id]
    F[GET /document/html] --> D
    G[GET /document/json] --> D
  end

  subgraph "Backend API (PHP)"
    E --> H[ActionImportDocument]
    H --> I[ImportDTOCreator]
    I --> J[HeliosImportService]
    J --> K[Database Import Procedures]
    K --> L[Save External Attributes]
    F --> M[ActionGetDocumentHtml]
    M --> N[HeliosService]
    N --> O[Twig Template]
    G --> P[ActionGetDocumentJson]
    P --> N
  end

  subgraph "Database (MS SQL)"
    K --> Q[(TabUniImportOrg)]
    K --> R[(TabUniImportOZ)]
    K --> S[(TabDokladyZbozi)]
    L --> T[(TabDokladyZboziExt)]
    N --> S
    N --> T
  end

  subgraph "Validation Layer"
    O --> U[HTML Parser]
    N --> V[JSON Response]
    U --> W[DeepDiff Compare]
    V --> W
    W --> X{Test Pass/Fail}
  end

Complete Test Execution Sequence

sequenceDiagram
  participant Test as BDD Test
  participant Steps as Step Definitions
  participant Config as ImportConfig
  participant Ctrl as Controller
  participant API as Backend API
  participant Import as ImportService
  participant DB as Database
  participant Retrieve as RetrievalService
  participant Valid as Validation
  Note over Test, Valid: Phase 1: Authentication Setup
  Test ->> Steps: Given I use tokens
  Steps ->> Ctrl: Set auth context
  Note over Test, Valid: Phase 2: Document Import
  Test ->> Steps: When I POST document data
  Steps ->> Config: Create ImportConfig
  Config ->> Config: Load payment_info JSON
  Config ->> Config: Build document body
  Config ->> Ctrl: POST with retry logic
  Ctrl ->> API: POST /import/document/id
  API ->> Import: Create DTOs
  Import ->> Import: Validate input
  Import ->> DB: Insert to staging tables
  DB ->> DB: Execute stored procedures
  DB ->> DB: Save to master tables
  Import ->> DB: Save external attributes (metadata)
  DB -->> API: Document ID (01118753)
  API -->> Ctrl: 201 Created {id}
  Ctrl -->> Steps: Response
  Steps ->> Valid: Verify status 201
  Steps ->> Valid: Validate ImportResponse
  Note over Test, Valid: Phase 3: HTML Retrieval
  Test ->> Steps: When I GET html
  Steps ->> Ctrl: GET /document/html
  Ctrl ->> API: GET /user/{cid}/document/{id}/html
  API ->> Retrieve: Get document by ID
  Retrieve ->> DB: Query TabDokladyZbozi
  Retrieve ->> DB: Query TabDokladyZboziExt
  DB -->> Retrieve: Document + Metadata
  Retrieve ->> API: Document object
  API ->> API: Render Twig template
  API -->> Ctrl: 200 OK HTML
  Ctrl -->> Steps: HTML response
  Steps ->> Valid: Parse HTML
  Steps ->> Valid: Build expected model
  Steps ->> Valid: DeepDiff compare
  Note over Test, Valid: Phase 4: JSON Retrieval
  Test ->> Steps: When I GET json
  Steps ->> Ctrl: GET /document/json
  Ctrl ->> API: GET /user/{cid}/document/{id}/json
  API ->> Retrieve: Get document by ID
  Retrieve ->> DB: Query all document tables
  DB -->> Retrieve: Complete document
  Retrieve ->> API: Document object
  API ->> API: Convert to array
  API -->> Ctrl: 200 OK JSON
  Ctrl -->> Steps: JSON response
  Steps ->> Valid: Parse JSON
  Steps ->> Valid: Build expected model
  Steps ->> Valid: DeepDiff compare
  Valid -->> Test: Test Result ✓

Scenario Definition

Feature File: /qa/features/ftmo_eval_global.feature

Scenario Outline: Import of issued invoices with payment info
Given I use "<User ID>" "user" token for accessing data
When I POST document data for "<User ID>" with these parameters
| Database    | Invoice Type   | Currency   | Price   | Payment info   |
| EVAL_GLOBAL | <Invoice Type> | <Currency> | <Price> | <Payment info> |
Then I verify that the response status code is: "201"
And I validate "ImportResponse" response body
When I GET "html" of imported document, using id from response
Then I verify that the response status code is: "200"
And I validate if "html" response of document "import" matches expected data
When I GET "json" of imported document, using id from response
Then I verify that the response status code is: "200"
And I validate if "json" response of document "import" matches expected data

Examples: Global
| ID  | User ID | Invoice Type     | Currency | Price    | Payment info  |
| 1.1 | CZ      | CHALLENGE_2_STEP | CZK      | 26162.79 | Special tip A |
| 1.2 | CZ      | CHALLENGE_2_STEP | USD      | 1242.73  | Special tip B |
| 1.3 | DE      | CHALLENGE_1_STEP | EUR      | 2160     | Points        |
| 1.4 | GB      | CHALLENGE_1_STEP | GBP      | 1904.49  | Challenge     |

Phase 1: Test Setup and Authentication

Class Structure - Python Test Framework

classDiagram
  class Context {
    +import_config: ImportConfig
    +response: Response
    +controller: Controller
    +users: dict
    +import_user: str
    +import_client: str
    +accessed_user: str
    +imported_documents: list
  }

  class ImportConfig {
    -_init: ImportConfigInitModel
    +db_setting: DatabaseSetting
    +platform: Platform
    +date_helper: DateHelper
    +document_timestamp: int
    +document_id: str
    +user_data: User
    +document_body: dict
    +build_import_document(user_data) dict
    +_create_invoice_item(item_type, amount) dict
    +_payment_info: dict
  }

  class ImportConfigInitModel {
    +database: str
    +invoice_type: str
    +currency: str
    +price: str
    +payment_info: str
    +discount_type: str
    +transaction_fee: str
  }

  class Controller {
    +auth: AuthController
    +timeout: int
    +import_document(body) Response
    +get_html_document(cid, doc_id) Response
    +get_json_document(cid, doc_id) Response
    -_get_headers() dict
    -_request(method, url) Response
  }

  class AuthController {
    +users: dict
    +get_token(user_id, client_id) Token
  }

  class InvoiceModel {
    +document_number: str
    +issue_date: str
    +total_amount: Decimal
    +items: list
    +payment_info: dict
    +from_html(html_dict, body)$ InvoiceModel
    +from_document_base(base)$ InvoiceModel
    +compare(other, exclude) dict
  }

  class JsonDocumentResponse {
    +id: str
    +document: JsonDocument
    +client: JsonClient
    +items: list
    +meta_data: JsonMetadata
    +from_document_base(base)$ JsonDocumentResponse
  }

  Context --> ImportConfig
  Context --> Controller
  ImportConfig --> ImportConfigInitModel
  ImportConfig --> User
  Controller --> AuthController
  InvoiceModel ..> ImportConfig: uses
  JsonDocumentResponse ..> ImportConfig: uses

1.1 Background Step - Service Token Setup

Step Definition: Given I use "CLIENT_NBO" "service" token for accessing data

File: /qa/steps/authentication_steps.py (implied from context)

Classes/Methods:

  • Context: Behave context object storing test state
  • AuthController: Manages authentication tokens
  • get_token(user_id, client_id): Retrieves or generates JWT token

Flow:

  1. Sets context.import_user = "CLIENT_NBO"
  2. Sets context.import_client = "service"
  3. Prepares authentication for subsequent service calls

1.2 Scenario Step - User Token Setup

Step Definition: Given I use "<User ID>" "user" token for accessing data

Classes/Methods:

  • Updates context.user with the specific User ID (e.g., "CZ", "DE", "GB")
  • Updates context.client = "user"
  • Loads user data from authentication controller

User Model:

User data is managed by AuthController and stored in context.users:

# User access from context
user_data = context.users[user_id]  # Returns User object

# User object properties (from tdss-sdk or internal model):
# - cid: Client ID (int)
# - name: Full name (str)
# - email: Email address (str)
# - country: Country code (str)
# - database: Database setting (DatabaseSetting)
# - Additional authentication properties

Environment Setup:

def before_feature(context: Context, _feature: Feature) -> None:
  """Initialize user context for each feature."""
  context.users = context.controller.auth.users

This makes user configurations available throughout test scenarios.


Phase 2: Document Import Request

2.1 Step Definition

Step: When I POST document data for "<User ID>" with these parameters

File: /qa/steps/document_steps.py

Function: post_document_data_for_user_step(context: Context, user_id: str)

@when('I POST document data for "{user_id}" with these parameters')
def post_document_data_for_user_step(context: Context, user_id: str):
  context.import_config = ImportConfig(table_row_to_dict(context.table))
  controller = context.controller[context.import_user, context.import_client]
  context.accessed_user = user_id
  _import_document_with_retry(context, controller, user_id)

2.2 ImportConfig Initialization

File: /qa/model/import_config.py

Class: ImportConfig

Constructor: __init__(self, init_data: dict)

Key Attributes:

  • _init: ImportConfigInitModel - Parsed initialization data
  • db_setting: DatabaseSetting - Database configuration (EVAL_GLOBAL)
  • platform: Platform - Platform type (HELIOS/XERO/NETSUITE)
  • date_helper: DateHelper - Date/time utilities
  • document_timestamp: int - Unix timestamp for document ID
  • is_received: bool - Whether invoice is received or issued
  • is_credit_note: bool - Whether price is negative

Initialization Flow:

  1. Parses table parameters into ImportConfigInitModel:
  2. database: "EVAL_GLOBAL"
  3. invoice_type: "CHALLENGE_2_STEP" or "CHALLENGE_1_STEP"
  4. currency: "CZK", "USD", "EUR", or "GBP"
  5. price: String amount (e.g., "26162.79")
  6. payment_info: "Special tip A", "Special tip B", "Points", or "Challenge"

  7. Resolves database setting: DatabaseSetting.EVAL_GLOBAL

  8. Determines platform from database setting
  9. Generates document timestamp: DateHelper().now_timestamp
  10. Calculates derived properties:
  11. is_credit_note = price.is_negative (False for positive prices)
  12. zero_total = total_price.is_zero (False)

2.2.1 ItemType and CisloUcetPol Mapping

File: /qa/enums/enums_item_types.py

Purpose: Maps invoice item types to their corresponding account numbers and descriptions

Enum Classes:

class ItemType(Enum):
  # Challenge items
  CHALLENGE_1_STEP = "challenge-1-step"
  CHALLENGE_2_STEP = "challenge-2-step"

  # Reward items
  REWARD_1_STEP = "reward-1-step"
  REWARD_2_STEP = "reward-2-step"

  # Affiliate items
  AFFILIATE = "affiliate"

  # Additional item types for sub-items
  PAYMENT_POINTS = "payment-points"
  REWARD_POINTS = "reward-points"


class CisloUcetPol(StrEnum):
  """Account numbers for Helios accounting system"""
  # Challenge accounts
  CHALLENGE_1_STEP = "602201"
  CHALLENGE_2_STEP = "602200"
  PAYMENT_POINTS_1_STEP = "324201"
  PAYMENT_POINTS_2_STEP = "324200"

  # Reward accounts
  REWARD_1_STEP = "518155"
  REWARD_2_STEP = "518150"
  REWARD_POINTS_1_STEP = "379801"
  REWARD_POINTS_2_STEP = "379800"

  # Affiliate account
  AFFILIATE = "518180"


class NazevSozNa1(StrEnum):
  """Item descriptions for invoices"""
  CHALLENGE_1_STEP = "FTMO Challenge 1-step"
  CHALLENGE_2_STEP = "FTMO Challenge 2-step"
  REWARD_1_STEP = "FTMO Reward 1-step"
  REWARD_2_STEP = "FTMO Reward 2-step"
  AFFILIATE = "Affiliate Commission"
  PAYMENT_POINTS = "Payment with points"
  REWARD_POINTS = "Reward points"

Mapping Logic:

@classmethod
def get_helios_account_number(
  cls,
  item_type: ItemType,
  sub_item_type: ItemType | None = None
) -> CisloUcetPol | None:
  """
  Maps ItemType to the appropriate CisloUcetPol account number.

  Examples:
      CHALLENGE_2_STEP → "602200"
      CHALLENGE_1_STEP → "602201"
      CHALLENGE_2_STEP + PAYMENT_POINTS → "324200"
  """

Usage in ImportConfig:

# Determine item type from init data
item_type = ItemType.from_string(self._init.invoice_type)

# Get account number for this item type
account_number = CisloUcetPol.get_helios_account_number(item_type)

# Get description
item_description = item_type.nazev_soz_na1  # Returns NazevSozNa1 value

Platform-Specific Handling:

  • HELIOS: Uses predefined account numbers from CisloUcetPol
  • XERO: Uses custom account mapping logic
  • NETSUITE: Uses different item mapping strategy

2.3 Building Import Document Body

flowchart TD
  A[Start: build_import_document] --> B[Initialize document_body structure]
  B --> C{Create main invoice item}
  C --> D[Add header conditional fields<br/>RezimMOSS, PoradoveCislo]
  D --> E[Add common fields<br/>Mnozstvi, RadaDokladu, Mena, etc.]
  E --> F[Add main item fields<br/>CisloZakazkyPol, NazevSozNa1, etc.]
  F --> G{Platform = HELIOS?}
  G -->|Yes| H[Add Helios-specific fields<br/>IDSklad, UKod, Kurz, etc.]
  G -->|No| I[Add platform-specific fields]
  H --> J{Has payment_info?}
  I --> J
  J -->|Yes| K[Load payment metadata from JSON<br/>payment_info_{name}.json]
K --> L[Add Metadata to invoice item]
J -->|No|M[Skip metadata]
L --> N[Append to document_body invoice array]
M --> N
N --> O{Has additional items?<br/>discount, fees, points}
O -->|Yes|P[Create additional items]
P --> N
O -->|No|Q[Return complete document_body]

Method: ImportConfig.build_import_document(user_data: User) -> dict

Flow:

2.3.1 Initialize Document Structure

self.document_body = {
  "init": self._init_db_data,
  "invoice": [],
  "organization": self._organization_data,
}

2.3.2 Create Main Invoice Item

Method: _create_invoice_item(item_type=None, amount=None)

Returns: Dictionary with invoice item data

Key Fields:

  • Header Conditional Fields:
  • RezimMOSS: MOSS mode (0 or 1)
  • PoradoveCislo: Document timestamp

  • Common Fields (from _common_invoice_item_fields()):

  • Mnozstvi: 1 (quantity)
  • RadaDokladu: Document series (e.g., "252", "292")
  • Mena: Currency code
  • DodFak: Invoice number (series + timestamp)
  • DruhPohybuZbo: Type of goods movement (13 or 14)
  • CisloOrg: Client organization number
  • DatPorizeni: Yesterday's date
  • Splatnost: Due date (14 days from now)
  • IDHlavicky: Invoice header ID
  • DIC2: Secondary VAT ID
  • AlternativniDIC: Alternative VAT ID
  • AlternativniDIC2: Second alternative VAT ID
  • PlatebniMetoda: Payment method
  • DatUhrady: Payment date (today if not zero total)
  • ZemeDPH: VAT country

  • Main Item Fields (from _main_invoice_item_fields()):

  • CisloZakazkyPol: Order item number from item type
  • NazevSozNa1: Sales entry name from ItemType enum
  • PopisDodavky: Delivery description
  • KodDanovyRezim: Tax mode code
  • KodDanoveKlicePol: VAT key code
  • CisloUcetPol: Account number (replaces deprecated Account field)
  • TypPolozkyId: Item type ID
  • SazbaDPHPol: VAT rate percentage
  • Cena: Price amount

  • Platform-Specific Fields (HELIOS):

  • IDSklad: Warehouse ID ("001")
  • SkupZbo: Goods group ("0")
  • RegCis: Registration number ("0")
  • TextPolozka: Item text ("1")
  • DotahovatSazby: Fetch rates (0)
  • DUZP: Tax liability date
  • UKod: Accounting code
  • VstupniCena: Input price type (0-5)
  • Text2: Document timestamp
  • Kurz: Exchange rate to CZK
  • DatumKurzu: Exchange rate date
  • DPHMimoCR: VAT outside CZ (0 or 1)
  • MistoZdaneni: Place of taxation (country)
  • RegionZdaneni: Taxation region (state)
  • Local currency fields (if applicable):
    • LokalniMena: Local currency name
    • KurzLokalniMena: Local currency exchange rate
    • CastkaDPHLokMena: VAT amount in local currency
    • LokalniMenaCelkem: Total in local currency
  • Cryptocurrency fields (for crypto payments):
    • KryptoMena: Cryptocurrency name (e.g., "BTC", "ETH")
    • KryptoMnozstvi: Cryptocurrency amount
    • KryptoKurz: Cryptocurrency exchange rate
  • E-invoice fields (for electronic invoices):

    • EfakturaCislo: E-invoice number
    • EfakturaUrl: E-invoice URL
  • Platform-Specific Fields (XERO):

  • CisloUcetPol: Xero-specific account mapping
  • TypPolozkyId: Xero item ID reference
  • TrackingCategory: Xero tracking category
  • Additional delay on import (1.5s) to avoid rate limits

  • Platform-Specific Fields (NETSUITE):

  • TiskoveId: Empty for NetSuite platform
  • Platform-specific item mapping

2.3.3 Add Payment Info Metadata

Key Logic:

if self._init.payment_info:
  self.document_body["invoice"][0]["Metadata"] = self._payment_info

Property: _payment_info

Method: Loads payment metadata from JSON file

File Path: /qa/test_data/payment_info_{snake_case_name}.json

For example:

  • "Special tip A" → payment_info_special_tip_a.json
  • "Special tip B" → payment_info_special_tip_b.json
  • "Points" → payment_info_points.json
  • "Challenge" → payment_info_challenge.json

Typical Metadata Structure:

{
  "Country": "Czech Republic",
  "City": "Prague",
  "Recipient Address": "Na Perštýně 342/1",
  "State": "Prague",
  "ZIP Code": "110 00"
}

2.3.4 Final Document Body Structure

{
  "init": {
    "JmenoDatabaze": "EVAL_GLOBAL",
    "prepareOnly": false,
    "ignoreDodFakCollision": false
  },
  "invoice": [
    {
      # Main invoice item with all fields
      "Metadata": {
        # Payment information metadata
      }
    }
  ],
  "organization": {
    "CisloOrg": 123456,  # User's client ID
    "Nazev": "John Doe",
    "ICO": "",
    "DIC": "",
    "Ulice": "Street Address",
    "Misto": "City",
    "PSC": "12345",
    "IdZeme": "CZ",
    "PravniForma": 1,  # Legal form (1=person, 2=company)
    # ... other organization fields
  }
}

2.4 Import with Retry Logic

stateDiagram-v2
  [*] --> Attempt: Start Import
  Attempt --> CheckResponse: POST document
  CheckResponse --> Success: 201 Created
  CheckResponse --> Conflict: 409 Conflict
  CheckResponse --> RateLimit: 400 + "429 Too Many Requests"
  CheckResponse --> OtherError: Other Status
  Conflict --> WaitNewSecond: Log Warning
  WaitNewSecond --> RegenerateTimestamp: Sleep 0.2s
  RegenerateTimestamp --> CheckRetries: Timestamp changed?
  RateLimit --> WaitBackoff: Log Warning
  WaitBackoff --> RegenerateTimestamp: Sleep (factor * attempt)
  CheckRetries --> Attempt: Retry count < MAX_TRIES
  CheckRetries --> MaxRetriesReached: Retry count >= MAX_TRIES
  Success --> StoreDocumentId: Extract document ID
  StoreDocumentId --> AppendToList: Add to imported_documents
  AppendToList --> [*]: Return
  MaxRetriesReached --> [*]: Log Warning & Return
  OtherError --> [*]: Return
  note right of Success
    Store document_id from response
    Example: "01118753"
  end note
  note right of WaitBackoff
    Incremental backoff:
    wait_time = BACKOFF_FACTOR * attempt
  end note

Function: _import_document_with_retry(context, controller, user_id)

File: /qa/steps/document_steps.py

Retry Conditions:

  1. 409 Conflict: Document timestamp collision
  2. Action: Wait for new second, regenerate timestamp, retry
  3. Max retries: context.settings.MAX_IMPORT_TRIES

  4. 400 with "429 Too Many Requests": Rate limiting

  5. Action: Incremental backoff wait, retry
  6. Wait time: RATE_LIMIT_BACKOFF_FACTOR * attempt_number

Success Path:

if context.response.status_code == 201:
  context.import_config.document_id = context.response.json()["id"]
  context.imported_documents.append(context.import_config)

  # Xero platform requires additional delay to avoid rate limits
  if context.import_config.db_setting.platform == Platform.XERO:
    time.sleep(1.5)  # Wait before subsequent requests

  return  # Success

Timestamp Regeneration:

# Ensure timestamp actually changed before retrying
old_timestamp = context.import_config.document_timestamp
while context.import_config.document_timestamp == old_timestamp:
  time.sleep(0.2)  # Wait for a new second
  context.import_config.regenerate_timestamp()

Incremental Backoff (Rate Limiting):

if context.response.status_code == 400 and "429 Too Many Requests" in context.response.text:
  wait_time = context.settings.RATE_LIMIT_BACKOFF_FACTOR * attempt
  steps_logger.warning(
    "Received 429 Too Many Requests. Retrying in %d seconds (%d/%d)...",
    wait_time, attempt, context.settings.MAX_IMPORT_TRIES
  )
  time.sleep(wait_time)
  context.import_config.regenerate_timestamp()
  continue  # Retry
if context.response.status_code == 201:
  context.import_config.document_id = context.response.json()["id"]
  context.imported_documents.append(context.import_config)

2.5 HTTP Request - Import Document

Client Method: Controller.import_document(body: dict)

File: /qa/service/accounting_controller.py

HTTP Details:

  • Method: POST
  • Endpoint: {base_url}/v1/service/import/document/id
  • Headers:
  • Authorization: Bearer {token.access_token}
  • Content-Type: application/json
  • Timeout: 30 seconds
  • Body: Complete document JSON

Phase 3: Backend Processing (PHP)

Backend Class Architecture

classDiagram
  class ActionImportDocument {
    -importService: PlatformImportInterface
    -request_data: array
    +import(Request, Response) Response
    -saveExternalAttributes(id, invoice, attributes)
  }

  class ImportDTOCreator {
    +createDTOTabUniImportOrg(array)$ DTOTabUniImportOrg
    +createDTOUniImportOZItems(array, guid)$ DTOUniImportOZ[]
    +createDTOTabUniImportOrgUp(DTO, service)$ DTOTabUniImportOrgUp
  }

  class DTOTabUniImportOrg {
    +CisloOrg: int
    +Nazev: string
    +ICO: string
    +DIC: string
    +Ulice: string
    +Misto: string
    +IdZeme: string
    +PravniForma: int
  }

  class DTOUniImportOZ {
    +CisloOrg: int
    +DruhPohybuZbo: int
    +Mnozstvi: int
    +RadaDokladu: string
    +DodFak: string
    +Cena: float
    +SazbaDPHPol: float
    +Mena: string
    +MistoZdaneni: string
    +ZemeDPH: string
    +50+ more properties
  }

  class PlatformImportInterface {
    <<interface>>
    +import(DTOTabUniImportOrg, DTOTabUniImportOrgUp, items, prepareOnly, ignoreDodFak, externalAttributes) array
    +validateInputData(DTO, items)
    +getIdByGuid(guid) int
    +saveExternalAttribute(id, value, name)
  }

  class HeliosImportService {
    -pdo: PDO
    -database: string
    +import(...) array
    +validateInputData(DTO, items)
    -flushImports()
    -validateImportItems(items)
    -insertTabUniImportOrg(DTO) int
    -insertTabUniImportOZ(items)
    -execImportProcedures()
    -getImportsErrors() array
    +saveExternalAttribute(id, value, name)
  }

  class HeliosService {
    -pdo: PDO
    -database: string
    -heliosImportService: HeliosImportService
    +getDocument(groups, documentId, userId) Document
    +getDocumentForView(requestData) Document
    -buildDocumentObject(data) Document
  }

  class ActionGetDocumentHtml {
    -platformService: PlatformService
    -twig: Twig
    -translationService: TranslationService
    +__invoke(Request, Response) Response
  }

  class ActionGetDocumentJson {
    -platformService: PlatformService
    +__invoke(Request, Response) Response
  }

  class Document {
    +id: string
    +documentNumber: string
    +date: string
    +totalAmount: float
    +items: DocumentItem[]
    +metadata: array
    +toArray() array
  }

  ActionImportDocument --> ImportDTOCreator
  ActionImportDocument --> PlatformImportInterface
  ImportDTOCreator --> DTOTabUniImportOrg
  ImportDTOCreator --> DTOUniImportOZ
  PlatformImportInterface <|.. HeliosImportService
  PlatformImportInterface <|.. XeroImportService
  PlatformImportInterface <|.. NetSuiteImportService
  ActionGetDocumentHtml --> HeliosService
  ActionGetDocumentJson --> HeliosService
  HeliosService --> Document
  HeliosService --> HeliosImportService

3.1 Routing

File: /accounting/app/routes.php

Route Definition:

$group->post('/document[/{return:id}]', ActionImportDocument::class);

Middleware Chain:

  1. XJWTPayloadMiddleware - JWT token validation
  2. MutexHelper - Prevents concurrent imports
  3. EnabledImportMiddleware - Checks if imports are enabled
  4. PlatformDatabaseMiddleware - Sets up platform/database context
  5. BodyParsingMiddleware - Parses JSON request body

Channel: Ch2 (internal service-to-service)

3.2 Action Handler

File: /accounting/src/Application/Actions/ActionImportDocument.php

Class: ActionImportDocument extends ActionImport

Method: import(Request $request, Response $response): Response

3.2.1 Extract Request Parameters

$return = $request->getAttribute('return', null);  // "id"
$prepareOnly = $this->request_data['init']['prepareOnly'] ?? false;  // false
$ignoreDodFakCollision = $this->request_data['init']['ignoreDodFakCollision'] ?? false;  // false
$request_organization = $this->request_data['organization'];
$request_invoices = $this->request_data['invoice'];

3.2.2 Validate External Attributes

Checks: TiskoveId, Metadata, PlatebniMetoda, AlternativniDIC

For our scenario: Validates that Metadata field is present and allowed for the database.

Extracts:

$externalAttributes['Metadata'] = $invoice['Metadata'];

3.2.3 Validate Print ID (TiskoveId)

foreach ($request_invoices as $invoice) {
    try {
        TemplateParam::getValidatedHash($invoice['TiskoveId'], true);
    } catch (PrintIdException $exception) {
        throw new HttpBadRequestException($request, $exception->getMessage());
    }
}

3.2.4 Create DTOs

Class: ImportDTOCreator

File: /accounting/src/Application/Helpers/ImportDTOCreator.php

Create Organization DTO

Method: createDTOTabUniImportOrg(array $request_organization, bool $replaceEmptyStrings)

Returns: DTOTabUniImportOrg

Mapping:

  • CisloOrg → Organization number
  • Nazev → Name
  • ICO → Company ID
  • DIC → VAT ID
  • Ulice → Street
  • Misto → City
  • PSC → Postal code
  • IdZeme → Country code
  • PravniForma → Legal form
  • Prijmeni → Last name
  • Jmeno → First name
  • Other fields...
Create Invoice Items DTOs

Method: createDTOUniImportOZItems(array $request_invoices, string $guid)

Returns: DTOUniImportOZ[]

File: /accounting/src/Application/Entities/Helios/DTOUniImportOZ.php

Class: DTOUniImportOZ

Properties (partial):

  • CisloOrg: int - Organization number
  • DruhPohybuZbo: int - Type of goods movement
  • Mnozstvi: int - Quantity
  • RadaDokladu: string - Document series
  • DodFak: string - Invoice number
  • DatPorizeni: string - Issue date
  • Splatnost: string - Due date
  • Cena: float - Price
  • Mena: string - Currency
  • SazbaDPHPol: float - VAT rate
  • CisloUcetPol: string - Account number (replaces deprecated Account field)
  • MistoZdaneni: string - Taxation place
  • ZemeDPH: string - VAT country
  • AlternativniDIC: string - Alternative VAT ID
  • AlternativniDIC2: string - Second alternative VAT ID
  • PlatebniMetoda: string - Payment method
  • Metadata: string - JSON metadata (payment info, etc.)
  • LokalniMena: string - Local currency name
  • KurzLokalniMena: float - Local currency exchange rate
  • CastkaDPHLokMena: float - VAT amount in local currency
  • LokalniMenaCelkem: float - Total in local currency
  • KryptoMena: string - Cryptocurrency name
  • KryptoMnozstvi: string - Cryptocurrency amount
  • KryptoKurz: string - Cryptocurrency exchange rate
  • EfakturaCislo: string - E-invoice number
  • EfakturaUrl: string - E-invoice URL
  • TypPolozkyId: int - Item type ID
  • CisloZakazkyPol: string - Order item number
  • NazevSozNa1: string - Sales entry name
  • ... (70+ total properties)

GUID: Unique identifier for this import operation

3.2.5 Validate Input Data

Method: $this->importService->validateInputData($DTOTabUniImportOrg, $obehZboziImpItems)

File: /accounting/src/Application/Services/Helios/HeliosImportService.php

Class: HeliosImportService implements PlatformImportInterface

Validations:

  1. Organization String Length Validation:
  2. Nazev: max 100 chars
  3. ICO: max 20 chars
  4. DIC: max 15 chars
  5. Ulice: max 100 chars
  6. Misto: max 100 chars
  7. PSC: max 10 chars
  8. IdZeme: max 3 chars
  9. ... and more

  10. Invoice Item Validation:

  11. Required fields are present
  12. String lengths within limits
  13. Numeric values in valid ranges

Throws: ImportAttributeException on validation failure

3.2.6 Create Update DTO

Method: ImportDTOCreator::createDTOTabUniImportOrgUp($DTOTabUniImportOrg, $this->importService)

Returns: DTOTabUniImportOrgUp

Logic:

  1. Checks if organization exists: $importService->getOrganizaceByCisloOrg()
  2. If exists, compares fields and marks changed fields for update
  3. Sets InterniVerze = 2 if any fields changed

3.2.7 Execute Import

flowchart TD
  A[Start: importService.import] --> B[Flush import tables<br/>TabUniImportOrg, OrgUp, OZ]
  B --> C[Validate import items<br/>Check DodFak uniqueness]
  C --> D[Insert organization<br/>to TabUniImportOrg]
  D --> E[Insert organization update<br/>to TabUniImportOrgUp]
  E --> F[Insert invoice items<br/>to TabUniImportOZ]
  F --> G{prepareOnly = false?}
  G -->|Yes| H[Execute stored procedures<br/>TabUniImportOrgI, OrgUp, OZI]
  G -->|No| Q[Return prepared status]
  H --> I[Procedures process data<br/>Insert into master tables]
  I --> J[Query for errors<br/>from import tables]
  J --> K{Errors found?}
  K -->|Yes| L[Flush import tables]
  K -->|No| M[Flush import tables]
  L --> N[Return error array]
  M --> O[Get document ID by GUID<br/>from TabDokladyZbozi]
  O --> P[Save external attributes<br/>to TabDokladyZboziExt]
  P --> R[Return empty array success]
  style H fill: #2B6CB0, stroke: #1A365D, color: #fff
  style I fill: #2B6CB0, stroke: #1A365D, color: #fff
  style P fill: #D97706, stroke: #9A5B03, color: #fff
  style R fill: #2F855A, stroke: #1C5B3A, color: #fff
  style N fill: #C53030, stroke: #822727, color: #fff

Method: $this->importService->import(...)

File: /accounting/src/Application/Services/Helios/HeliosImportService.php

Method Signature:

public function import(
    DTOTabUniImportOrg $DTOTabUniImportOrg,
    DTOTabUniImportOrgUp $DTOTabUniImportOrgUp,
    array $items,
    bool $prepareOnly = false,
    bool $ignoreDodFakCollision = false,
    array $externalAttributes = []
): array

Steps:

Step 1: Flush Import Tables
$this->flushImports();
  • Deletes all rows from TabUniImportOrg
  • Deletes all rows from TabUniImportOrgUp
  • Deletes all rows from TabUniImportOZ
Step 2: Validate Import Items
$this->validateImportItems($items, $ignoreDodFakCollision);
  • Ensures items array is not empty
  • Validates DodFak uniqueness (unless ignoreDodFakCollision = true)
  • Checks for collision with existing documents
Step 3: Insert Organization Data
$id_imp_table = $this->insertTabUniImportOrg($DTOTabUniImportOrg, $items[0]);
  • Inserts into TabUniImportOrg table
  • Returns auto-generated ID
Step 4: Insert Organization Update Data
$this->insertTabUniImportOrgUp($DTOTabUniImportOrgUp, $id_imp_table);
  • Inserts into TabUniImportOrgUp table
  • Links to organization record via IdImpTable
Step 5: Insert Invoice Items
$this->insertTabUniImportOZItems($items, $DTOTabUniImportOrg, $externalAttributes);

Inserts into: TabUniImportOZ table

SQL Statement (partial):

INSERT INTO {schema}.TabUniImportOZ (CisloOrg,
                                     IDSklad,
                                     RegCis,
                                     SkupZbo,
                                     DruhPohybuZbo,
                                     TextPolozka,
                                     VstupniCena,
                                     Mnozstvi,
                                     NazevSozNa1,
                                     PopisDodavky,
                                     UKod,
                                     RadaDokladu,
                                     IDHlavicky,
                                     DodFak,
                                     DatPorizeni,
                                     DUZP,
                                     Splatnost,
                                     Cena,
                                     SazbaDPHPol,
                                     Mena,
                                     Kurz,
                                     DatumKurzu,
                                     CisloZakazkyPol,
                                     RezimMOSS,
                                     DPHMimoCR,
                                     MistoZdaneni,
                                     ZemeDPH,
                                     RegionZdaneni,
                                     DIC2,
                                     PoradoveCislo,
  -- ... and many more columns
)
VALUES (?, ?, ?, ...)

Note: Metadata is NOT stored in this table (stored separately as external attributes)

Step 6: Execute Helios Import Procedures
if ($prepareOnly === false) {
    $this->execImportProcedures();
    $errors = $this->getImportsErrors();
    $this->flushImports();
}

Stored Procedures:

  1. TabUniImportOrgI - Processes organization import
  2. TabUniImportOrgUp - Processes organization updates
  3. TabUniImportOZI - Processes invoice items import

These procedures:

  • Validate business rules
  • Insert/update data in main Helios tables:
  • TabCisOrg - Organization master data
  • TabDokladyZbozi - Document headers
  • TabDokladyZboziPol - Document line items
  • TabZakazka - Order/task data
  • Generate document IDs
  • Handle accounting entries
Step 7: Check for Errors
$errors = $this->getImportsErrors();

Query:

SELECT TOP 10 Chyba
FROM {schema}.TabUniImportOrg
WHERE Chyba IS NOT NULL
UNION
SELECT TOP 10 Chyba
FROM {schema}.TabUniImportOZ
WHERE Chyba IS NOT NULL

Returns: Array of error messages (empty on success)

Step 8: Flush Import Tables Again
$this->flushImports();

3.2.8 Get Document ID

Method: $this->importService->getIdByGuid($guid)

Query:

SELECT TOP 1 ID
FROM {schema}.TabDokladyZbozi
WHERE GUID = ?

Returns: Internal Helios document ID (e.g., 118753)

3.2.9 Save External Attributes

For Metadata:

if (isset($invoice['Metadata']) && $invoice['Metadata']) {
    $fixed_metadata = [];
    foreach ($invoice['Metadata'] as $key => $value) {
        if ($value !== null && trim((string)$value) !== '') {
            $fixed_metadata[$key] = trim((string)$value);
        }
    }
    $this->importService->saveExternalAttribute(
        (int)$id,
        (string)json_encode($fixed_metadata),
        '_json_metadata'
    );
}

Method: saveExternalAttribute(int $id, string $value, string $name)

Table: TabDokladyZboziExt

SQL:

INSERT INTO {schema}.TabDokladyZboziExt (ID, Hodnota, Nazev)
VALUES (?, ?, ?)
ON DUPLICATE KEY
UPDATE Hodnota =
VALUES (Hodnota)

Stored Data:

  • ID: Document ID (e.g., 118753)
  • Nazev: "_json_metadata"
  • Hodnota: JSON string of metadata:
    {"Country":"Czech Republic","City":"Prague","Recipient Address":"Na Perštýně 342/1","State":"Prague","ZIP Code":"110 00"}
    

Other External Attributes Saved:

  • _tiskove_id - Print ID
  • _platebni_metoda - Payment method
  • _alternativni_dic - Alternative VAT ID
  • _dph_mimo_cr - VAT outside CZ
  • _dph_komponenty - VAT components
  • _DIC2 - Secondary VAT ID
  • _castka_dph_lok_mena - VAT in local currency
  • _kurz_lokalni_mena - Local currency rate
  • _lokalni_mena - Local currency name
  • _lokalni_mena_celkem - Total in local currency
  • _misto_zdaneni - Place of taxation
  • _region_zdaneni - Taxation region
  • _alternativni_dic2 - Second alternative VAT ID
  • _efaktura_cislo - E-invoice number
  • _efaktura_url - E-invoice URL
  • _krypto_mena - Cryptocurrency name
  • _krypto_mnozstvi - Cryptocurrency amount
  • _krypto_kurz - Cryptocurrency rate

3.2.10 Build Response

Success Response (201 Created):

return $this->createJSONResponse(
    $response,
    [
        'id' => $this->settings->get(
            $this->platform::PLATFORM_NAME
        )['database_ids'][$this->importService->getDatabase()] . $id,
    ],
    201
);

Response Body:

{
  "id": "01118753"
}

ID Format: {platform_prefix}{document_id}

  • Platform prefix: "01" for EVAL_GLOBAL database
  • Document ID: Internal Helios ID (e.g., "118753")
  • Full ID: "01118753"

Phase 4: Validation - Response Status

File: /qa/steps/verification_steps.py

Step: Then I verify that the response status code is: "201"

Function: local_verify_status_code_step(context: Context, status: int)

@then('I verify that the response status code is: "{status:d}"')
def local_verify_status_code_step(context: Context, status: int):
  validate_http_response_code(
    context.response,
    status,
    parse_json=False,
  )

Function: validate_http_response_code(response, expected_status, parse_json)

Validation:

actual_status = response.status_code
if actual_status != expected_status:
  error_msg = f"Expected status code {expected_status}, but got {actual_status}"
  raise AssertionError(error_msg)

Expected: 201 Actual: 201 (from successful import)


Phase 5: Validation - Import Response Body

Step: And I validate "ImportResponse" response body

Function: verify_body_step(context: Context, model: str)

File: /qa/steps/verification_steps.py

@then('I validate "{model}" response body')
def verify_body_step(context: Context, model: str) -> None:
  if not hasattr(responses, model):
    raise TestRuntimeError(f"Response model '{model}' not found")
  validator = TypeAdapter(getattr(responses, model))

  response_json = context.response.json()
  validator.validate_python(response_json)  # Pydantic validation

Model: ImportResponse

File: /qa/model/document_import.py

class ImportResponse(MixedCaseModel):
  id: str

Validation: Ensures response has a valid id field (e.g., "01118753")


Phase 6: Retrieve HTML Document

Step: When I GET "html" of imported document, using id from response

Function: get_imported_document_step(context: Context, doc_type: str)

File: /qa/steps/document_steps.py

@when('I GET "{doc_type}" of imported document, using id from response')
def get_imported_document_step(context: Context, doc_type: str):
  doc_id = context.import_config.document_id  # "01118753"
  client_id = context.users[context.accessed_user].cid  # User's client ID
  controller = context.controller[context.user, context.client]

  match doc_type:
    case "html":
      context.response = controller.get_html_document(client_id, doc_id)

6.1 HTTP Request - Get HTML Document

Client Method: Controller.get_html_document(cid: int, document_id: str)

HTTP Details:

  • Method: GET
  • Endpoint: {base_url}/v1/user/{cid}/document/{document_id}/html
  • Headers: Authorization Bearer token
  • Example: /v1/user/123456/document/01118753/html

6.2 Backend Processing - HTML Document

File: /accounting/src/Application/Actions/ActionGetDocumentHtml.php

Class: ActionGetDocumentHtml

Method: __invoke(Request $request, Response $response): Response

6.2.1 Parse Document ID

$documentId = $request->getAttribute('documentId');  // "01118753"
$userId = $request->getAttribute('userId');  // "123456"

$documentIdPart = [];
preg_match('/([0-9]{2})(\d*)/', $documentId, $documentIdPart);
// $documentIdPart[1] = "01" (platform/database prefix)
// $documentIdPart[2] = "118753" (internal ID)

6.2.2 Get Platform Service

$document = $this->platformService->getPlatform(id: $documentIdPart[1])
    ->getDocument(
        $request->getAttribute('x-jwt-group'),
        $documentId,
        $userId
    );

Class: PlatformService

Method: getPlatform(id: string)

Returns: Platform-specific service (e.g., HeliosService, XeroService, NetSuiteService)

6.2.3 Retrieve Document from Database

File: /accounting/src/Application/Services/Helios/HeliosService.php

Class: HeliosService implements PlatformInterface

Method: getDocument(array $groups, string $documentId, string $userId): ?Document

Steps:

Step 1: Parse Document ID
$documentNumberParsed = DocumentNumberParser::parseDocumentNumber($documentId);
// Returns: ["prefix" => "01", "id" => "118753"]
Step 2: Query Document Header

Table: TabDokladyZbozi

SQL (simplified):

SELECT tdz.ID,
       tdz.CisloOrg,
       tdz.DatPorizeni,
       tdz.DUZP,
       tdz.DatumSplatnosti,
       tdz.DodFak,
       tdz.DodFakKV,
       tdz.DruhPohybuZbo,
       tdz.RadaDokladu,
       tdz.Stornovan,
       tdz.Celkem,
       tco.Nazev,
       tco.Jmeno,
       tco.Prijmeni,
-- ... many more fields
FROM {schema}.TabDokladyZbozi tdz
JOIN {schema}.TabCisOrg tco
ON tdz.CisloOrg = tco.CisloOrg
WHERE tdz.ID = ?
  AND tdz.CisloOrg = ?

Parameters:

  • ID: 118753
  • CisloOrg: Client organization number (validated against userId)
Step 3: Query Document Line Items

Table: TabDokladyZboziPol

SQL (simplified):

SELECT tdzp.PopisDodavky,
       tdzp.Cena,
       tdzp.Mnozstvi,
       tdzp.SazbaDPH,
       tdzp.ZalohovaDanCelkem,
       tdzp.SoucetPolozky,
       tdzp.CastkaSDPH,
       tz.CisloZakazkyPol,
-- ... more fields
FROM {schema}.TabDokladyZboziPol tdzp
JOIN {schema}.TabZakazka tz
ON tdzp.IDZakazka = tz.IDZakazka
WHERE tdzp.ID = ?
ORDER BY tdzp.IDPolozka

Parameter: ID: 118753

Step 4: Query External Attributes

Table: TabDokladyZboziExt

SQL:

SELECT Nazev, Hodnota
FROM {schema}.TabDokladyZboziExt
WHERE ID = ?

Parameter: ID: 118753

Step 5: Query Text Items

Table: TabOZTxtPol

SQL:

SELECT Text1, Text2, Text3
FROM {schema}.TabOZTxtPol
WHERE ID = ?

Parameter: ID: 118753

Step 6: Build Document Object

Class: Document

File: /accounting/src/Application/Entities/Accounting/Document.php

Properties (partial):

  • id: string - Document ID
  • documentNumber: string - Invoice number (DodFak)
  • referenceNumber: string - Reference (DodFakKV)
  • date: string - Issue date
  • dueDate: string - Due date
  • taxableDate: string - Tax liability date
  • currency: string - Currency code
  • exchangeRate: float - Exchange rate
  • totalAmount: float - Total with VAT
  • totalAmountWithoutVat: float - Total without VAT
  • vatAmount: float - VAT amount
  • customerName: string - Client name
  • customerAddress: string - Client address
  • customerCity: string - Client city
  • customerPsc: string - Client postal code
  • customerCountry: string - Client country
  • customerVatId: string - Client VAT ID
  • items: array - Document line items
  • translations: array - Translated content (for AR countries)
  • metadata: array - Payment information metadata

Document Items: Each item is a DocumentItem object:

  • description: string
  • quantity: int
  • pricePerUnit: float
  • vatRate: float
  • totalPrice: float
  • totalPriceWithVat: float
  • orderNumber: string

Metadata Parsing:

if (isset($externalAttributes['_json_metadata'])) {
    $metadata = json_decode($externalAttributes['_json_metadata'], true);
    $document->setMetadata($metadata);
}

Metadata Structure:

[
    "Country" => "Czech Republic",
    "City" => "Prague",
    "Recipient Address" => "Na Perštýně 342/1",
    "State" => "Prague",
    "ZIP Code" => "110 00"
]
Step 7: Load Translations (if applicable)

Method: TranslationService::loadTranslations(Document $document)

File: /accounting/src/Application/Helpers/TranslationService.php

For Arabic Countries (SA, AE, OM):

  • Loads translated item descriptions
  • Loads translated customer/supplier names
  • Adds translations to document object

6.2.4 Render HTML Template

flowchart TD
  A[Document Object with Metadata] --> B[Twig Template Engine]
  B --> C[Load invoice.html.twig]
  C --> D[Render Header<br/>Logo, Title]
  D --> E[Render Supplier Info<br/>FTMO Company Details]
  E --> F[Render Customer Info<br/>Client Details]
  F --> G[Render Document Info<br/>Number, Dates, Currency]
  G --> H[Render Line Items Table<br/>Description, Qty, Price, VAT]
  H --> I[Render Totals<br/>Subtotal, VAT, Total]
  I --> J{invoice.metadata<br/>not empty?}
  J -->|Yes| K[Render Payment Information Section]
  K --> L[Create table with metadata]
  L --> M[Loop through metadata key-value]
  M --> N[Render: Country, City, Address, etc.]
  J -->|No| O[Skip payment section]
  N --> P[Render Translations<br/>if Arabic country]
  O --> P
  P --> Q[Render Footer<br/>Legal text, Terms]
  Q --> R[Return Complete HTML]

Template Engine: Twig

Template File: /accounting/templates/invoice.html.twig

Twig Render:

return $this->twig->render($response, 'invoice.html.twig', [
    'invoice' => $document,
    'logo' => $this->assetsHelper->getDataImageBase64('FTMO-logo.svg'),
    'style' => $this->assetsHelper->getRawCSS('all-documents-html.css'),
    'downloadUrl' => '/v1/user/' . $userId . '/document/' . $documentId . '/pdf',
]);

Template Variables:

  • invoice: Document object with all data
  • logo: Base64-encoded SVG logo
  • style: Inline CSS styles
  • downloadUrl: PDF download link

Template Sections:

  1. Header: Company logo, invoice title
  2. Supplier Info: FTMO company details
  3. Customer Info: Client details
  4. Document Info: Invoice number, dates, currency
  5. Line Items Table: Items with quantities, prices, VAT
  6. Totals: Subtotal, VAT, Total
  7. Payment Information: Metadata fields (if present)
  8. Translations: Arabic translations (if applicable)
  9. Footer: Legal text, terms

Payment Information Rendering:

{% if invoice.metadata is not empty %}
<div class="payment-info">
    <h3>Payment Information</h3>
    <table>
        {% for key, value in invoice.metadata %}
        <tr>
            <td>{{ key }}:</td>
            <td>{{ value }}</td>
        </tr>
        {% endfor %}
    </table>
</div>
{% endif %}

Example Rendered Payment Info:

<div class="payment-info">
  <h3>Payment Information</h3>
  <table>
    <tr>
      <td>Country:</td>
      <td>Czech Republic</td>
    </tr>
    <tr>
      <td>City:</td>
      <td>Prague</td>
    </tr>
    <tr>
      <td>Recipient Address:</td>
      <td>Na Perštýně 342/1</td>
    </tr>
    <tr>
      <td>State:</td>
      <td>Prague</td>
    </tr>
    <tr>
      <td>ZIP Code:</td>
      <td>110 00</td>
    </tr>
  </table>
</div>

6.2.5 Return HTML Response

Status Code: 200 OK Content-Type: text/html Body: Fully rendered HTML invoice document


Phase 7: Validation - HTML Document

Step: Then I verify that the response status code is: "200"

Validation: Confirms HTML was retrieved successfully

Step: And I validate if "html" response of document "import" matches expected data

Function: validate_document_response_matches_expected_data_step(context, data_type, document)

File: /qa/steps/document_steps.py

@then('I validate if "{data_type}" response of document "{document}" matches expected data')
def validate_document_response_matches_expected_data_step(
  context: Context,
  data_type: str,
  document: str
):
  document_base = AccountingDocumentBase(context.import_config)

  match data_type, document:
    case "html", "import":
      html_dict = find_html_values(context.response.text)

      # Check default invoice
      received_data = InvoiceModel.from_html(html_dict, document_base.config.document_body)
      expected_data = InvoiceModel.from_document_base(document_base)
      deepdiff_compare(expected_data, received_data, EXCLUDED_FIELDS_HTML, EXCLUDED_REGEX)

      # Check for translated section
      translation_model = TranslationFactory.get_translation_model(document_base.config.vat_country)
      if translation_model:
        received_data_translated = translation_model.from_html(html_dict, document_base.config.document_body)
        expected_data_translated = translation_model.from_import_body(document_base)
        deepdiff_compare(expected_data_translated, received_data_translated)

Translation Support:

For specific VAT countries, the system validates additional translated content sections:

  • Supported Countries: AE (UAE), SA (Saudi Arabia), OM (Oman)
  • Translation Model: ArabicInvoiceModel (from /qa/model/document_html_ar.py)
  • Validates:
  • Supplier information (company name, address) in Arabic
  • Customer information in Arabic
  • Item descriptions in Arabic
  • Labels in Arabic (invoice, date, amount, etc.)
  • Footer legal text in Arabic

Phase 8: Retrieve JSON Document

Step: When I GET "json" of imported document, using id from response

Function: Same as HTML retrieval, but doc_type = "json"

case
"json":
context.response = controller.get_json_document(client_id, doc_id)

8.1 HTTP Request - Get JSON Document

Client Method: Controller.get_json_document(cid: int, document_id: str)

HTTP Details:

  • Method: GET
  • Endpoint: {base_url}/v1/user/{cid}/document/{document_id}/json
  • Example: /v1/user/123456/document/01118753/json

8.2 Backend Processing - JSON Document

File: /accounting/src/Application/Actions/ActionGetDocumentJson.php

Class: ActionGetDocumentJson extends ActionAbstract

Method: __invoke(Request $request, Response $response): Response

Flow:

  1. Parse document ID (same as HTML)
  2. Get platform service (same as HTML)
  3. Retrieve document from database (same as HTML - reuses getDocument() method)
  4. Convert document to array: $document->toArray()
  5. Return JSON response

Document::toArray() Structure:

[
    "id" => "01118753",
    "document" => [
        "DodFak" => "25217123456789",
        "DatPorizeni" => "2026-01-28",
        "DatumSplatnosti" => "2026-02-11",
        "DUZP" => "2026-01-28",
        "Celkem" => 26162.79,
        "CelkemSDPH" => 26162.79,
        "CelkemBezDPH" => 21621.31,
        "Mena" => "CZK",
        "Kurz" => 1.0,
        "DruhPohybuZbo" => 13,
        "RadaDokladu" => "252",
        "DodFakKV" => "",
        "PoradoveCislo" => 17123456789,
        "Stornovan" => 0
    ],
    "client" => [
        "CisloOrg" => 123456,
        "Nazev" => "Jan Novák",
        "Jmeno" => "Jan",
        "Prijmeni" => "Novák",
        "Ulice" => "Hlavní 123",
        "Misto" => "Praha",
        "PSC" => "11000",
        "IdZeme" => "CZ",
        "DIC" => "",
        "PravniForma" => 1
    ],
    "items" => [
        [
            "PopisDodavky" => "FTMO Challenge 2-step",
            "Cena" => 26162.79,
            "Mnozstvi" => 1,
            "SazbaDPH" => 21.0,
            "ZalohovaDanCelkem" => 5541.48,
            "SoucetPolozky" => 21621.31,
            "CastkaSDPH" => 26162.79,
            "CisloZakazkyPol" => "1001"
        ]
    ],
    "metaData" => [
        "Country" => "Czech Republic",
        "City" => "Prague",
        "Recipient Address" => "Na Perštýně 342/1",
        "State" => "Prague",
        "ZIP Code" => "110 00",
        "lastChange" => "2026-01-28 10:15:30"
    ],
    "documentCorrection" => null,
    "translations" => []
]

Response:

  • Status Code: 200 OK
  • Content-Type: application/json
  • Body: JSON document structure

Phase 9: Validation - JSON Document

Step: Then I verify that the response status code is: "200"

Validation: Confirms JSON was retrieved successfully

Step: And I validate if "json" response of document "import" matches expected data

Function: Same validation function, different case:

case
"json", "import":
received_data = JsonDocumentResponse(**context.response.json())
expected_data = JsonDocumentResponse.from_document_base(document_base)
deepdiff_compare(expected_data, received_data, EXCLUDED_FIELDS_JSON, EXCLUDED_REGEX)

9.1 Build Expected JSON Model

Class: JsonDocumentResponse (Pydantic Model)

File: /qa/model/document_json.py

Base Class: MixedCaseModel (custom Pydantic BaseModel with PascalCase aliases)

Method: JsonDocumentResponse.from_document_base(document_base)

Structure:

class JsonDocumentResponse(MixedCaseModel):
  id: str
  document: JsonDocument
  client: JsonClient
  documentItems: list[JsonDocumentItem]
  metaData: JsonDocumentMetaData
  company: JsonDocumentCompany
  document_correction: Optional[Any] = None
  translations: list[dict] = []

  # Internal fields (excluded from comparison)
  document_id: Annotated[str, Field(exclude=True)]
  user_id: Annotated[str, Field(exclude=True)]
  db: Annotated[str | None, Field(exclude=True)] = None

Sub-Models:

class JsonDocumentItem(MixedCaseModel):
  Popis: str  # Description
  SazbaDPH: TryToFloat  # VAT rate
  CCbezDaniVal: TryToFloat  # Amount without VAT
  CisloZakazkyPol: NoneToStr  # Order item number
  CCsDaniVal: TryToFloat  # Amount with VAT
  Mnozstvi: int  # Quantity
  CisloUcetPol: NoneToStr = ""  # Account number (new field)


class JsonDocument(MixedCaseModel):
  DodFak: str  # Invoice number
  DatPorizeni: TryDateOrStr  # Issue date
  Splatnost: TryDateOrStr  # Due date
  DUZP: TryDateOrStr  # Tax liability date
  Mena: str  # Currency
  SumaValPoZao: TryToFloat  # Amount to pay
  Kurz: TryToFloat  # Exchange rate
  # ... other document fields

Pydantic Features:

  • Automatic type coercion with TryToFloat, TryDateOrStr
  • Custom validators for complex transformations
  • model_dump() for comparison
  • Field aliases for PascalCase JSON keys

9.2 Parse Received JSON

Pydantic Model: JsonDocumentResponse(**context.response.json())

Validates and parses response into structured model

9.3 Deep Comparison

Function: deepdiff_compare(expected, received, EXCLUDED_FIELDS_JSON, EXCLUDED_REGEX)

Library: DeepDiff

Float Comparison Strategy:

Instead of using math_epsilon, float values are rounded to a consistent precision before comparison:

def round_floats(obj: Any, decimal_places: int = 2) -> Any:
  """Recursively round float values in nested structures"""
  if isinstance(obj, float):
    return round(obj, decimal_places)
  elif isinstance(obj, dict):
    return {k: round_floats(v, decimal_places) for k, v in obj.items()}
  elif isinstance(obj, list):
    return [round_floats(item, decimal_places) for item in obj]
  return obj


# Apply rounding before comparison
rounded_expected = round_floats(expected.model_dump(), decimal_places=2)
rounded_received = round_floats(received.model_dump(), decimal_places=2)

Excluded Fields:

EXCLUDED_FIELDS_JSON = [
  "root['client']['PravniForma']",  # May vary based on backend logic
  "root['documentCorrection']",  # Not used in this scenario
  "root['document']['PoradoveCislo']",  # Internal sequence number
  "root['metaData']['lastChange']",  # Timestamp differs
]

EXCLUDED_REGEX = [
  r"root\['document'\]\['.*'\]",  # Can exclude specific document patterns
]

Validates:

  • Document header fields (number, dates, amounts)
  • Client information (name, address, tax IDs)
  • Line items with CisloUcetPol (account numbers)
  • Descriptions, prices, quantities, VAT rates
  • Payment metadata (Country, City, Address, State, ZIP)
  • Currency and exchange rate
  • VAT calculations (with rounded floats)
  • Local currency conversions (if applicable)
  • Cryptocurrency data (if applicable)

Comparison Method:

def deepdiff_compare(
  doc_sent: BaseModel,
  doc_received: BaseModel,
  excluded_fields: list[str] | None = None,
  excluded_regex: list[str] | None = None,
):
  diff = doc_sent.compare(
    doc_received,
    exclude_paths=excluded_fields,
    exclude_regex_paths=excluded_regex
  )
  assert not diff, json.dumps(diff, default=str)

Success: All fields match expected values (test passes)