openapi: 3.0.0
info:
    version: '1.0'
    title: 'Qomon Transaction Import API'
    description: "Internal API for importing transactions from a CSV file.\n\n## Import flow\n\n1. **Upload** `POST /import/transactions` — upload the CSV file (multipart/form-data, field `file`)\n2. **Preview** `GET /import/transactions/{id}/file-preview` — visually verify the raw file\n3. **Columns** `GET /import/transactions/{id}/columns` — check which columns are recognised\n4. **Mapping** `POST /import/transactions/{id}/mapping` — map non-standard column names *(skip if all columns match)*\n5. **Validate** `POST /import/transactions/{id}/validate` — dry-run validation, no data written\n6. **Enqueue** `POST /import/transactions/{id}/enqueue` — upload to S3 and start async processing\n7. **Poll** `GET /import/transactions/{id}` — watch `status` until `completed` or `failed`\n8. **Errors** `GET /import/transactions/{id}/invalid-lines` — inspect invalid rows *(only on failure)*\n\n## All-or-nothing guarantee\n\nIf any row fails validation during async processing, **nothing is published** and the job transitions to `failed`.\nFix the bad rows and re-upload.\n\n## CSV format\n\nDownload a ready-to-use template from `GET /import/transactions/template`.\n\n**Contact identifier** (at least one required per row):\n`contact_id`, `nationbuilder_id`, `external_id`, `stripe_id`, `email`\n\n**Required per row:** `type`, `amount`, `currency`, `date`, `payment_method_kind`\n\n**type** must be `donation` or `membership`."
servers:
    -
        description: 'Qomon gateway'
        url: 'https://api.qomon.app'
security:
    -
        apiKey: []
tags:
    -
        name: 'Transaction Import'
        description: 'Upload, configure, validate and enqueue CSV transaction imports.'
paths:
    /import/transactions/available-columns:
        get:
            tags: ['Reference Data']
            summary: 'List available transaction columns'
            description: "Returns the **static** list of all system columns available for transaction/donation/membership imports.\n\nUse this to build the column mapping UI — show these as \"target\" options\nalongside the user's CSV column headers.\n\nThese columns are namespaced:\n- `transaction.*` — core transaction fields\n- `donation.*` — donation-specific fields\n- `membership.*` — membership-specific fields\n\nPlus matching fields (flat): `contact_id`, `nationbuilder_id`, `external_id`, `stripe_id`\n"
            operationId: getTransactionImportAvailableColumns
            responses: {'200': {description: 'List of column definitions', content: {application/json: {schema: {type: object, properties: {status: {type: string, example: success}, data: {type: array, items: {type: object, description: "Describes a single mappable field for the transaction import mapping UI.\nReturned by GET /import/transactions/available-columns.\n", properties: {value: {type: string, description: 'Namespaced field key', example: transaction.amount}, label: {type: string, description: 'Display label (frontend will translate via i18n)', example: transaction.amount}, required: {type: boolean, description: 'Whether mapping this field is mandatory for a valid import'}, field_type: {type: string, enum: [string, integer, date, json, boolean], description: 'Expected data type'}}}}}}, example: {status: success, data: [{value: transaction.type, label: transaction.type, required: true, field_type: string}, {value: transaction.amount, label: transaction.amount, required: true, field_type: integer}, {value: transaction.date, label: transaction.date, required: true, field_type: date}, {value: transaction.currency, label: transaction.currency, required: false, field_type: string}, {value: transaction.payment_method_kind, label: transaction.payment_method_kind, required: false, field_type: string}, {value: donation.price_name, label: donation.price_name, required: false, field_type: string}, {value: membership.price_name, label: membership.price_name, required: false, field_type: string}]}}}}}
    /import/transactions/template:
        get:
            tags: ['Reference Data']
            summary: 'Download CSV template'
            description: "Returns a pre-filled CSV template with all supported column headers and two example rows.\nUse this as a starting point to prepare an import file.\n\nThe template uses canonical namespaced column names so no mapping step\nis required if the user keeps them as-is.\n"
            operationId: getTransactionImportTemplate
            responses: {'200': {description: 'CSV file download', content: {text/csv: {schema: {type: string, format: binary}}}}}
    /imports:
        post:
            tags: ['Unified Import']
            summary: 'Upload a CSV or XLSX file'
            description: "Upload a file to create a new import. This is the **same endpoint** regardless\nof whether the file contains contacts, transactions, or both.\n\nAt this stage the import has no `import_kind` — it is determined later at the\nmapping step based on what the user maps the columns to.\n\n**Supported file formats:** CSV (.csv), Excel (.xlsx)\n**Max file size:** 250 MB\n"
            operationId: uploadImport
            requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file], properties: {file: {type: string, format: binary, description: 'The CSV or XLSX file to import'}}}}}}
            responses: {'201': {description: 'File uploaded, import created', content: {application/json: {schema: {type: object, properties: {import: {type: object, description: "A unified import record from the `imports` table.\nUsed for contacts, transactions, and mixed imports alike.\n\nThe `import_kind` field determines how the system processes this import:\n- `\"\"` or `\"contacts\"` — contact import (legacy behavior, processed by importfileworker)\n- `\"transactions\"` — transaction-only import (processed by import-transaction service)\n- `\"mixed\"` — contacts first, then transactions automatically triggered after\n\nStatus tracking is done via the `import_statuses` table (see GET /imports/{id}/statuses).\n", properties: {id: {type: string, format: uuid, example: 722e168e-390e-4259-8206-dd2bba1f44b8}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}, customer_account_id: {type: integer, description: 'The group/organization this import belongs to'}, file_name: {type: string, description: 'Server-side file path', example: /uploads/722e168e.csv}, original_file_name: {type: string, description: 'Original filename as uploaded by the user', example: my_transactions_2026.csv}, name: {type: string, description: 'User-facing import name (editable)', example: 'Import Mai 2026'}, import_kind: {type: string, description: "Determined at the mapping step. Empty string means legacy contacts import.\n", enum: ["", contacts, transactions, mixed], example: transactions}, matching_field: {type: string, description: "The contact field used to link transaction rows to existing contacts.\n- **transactions**: required. One of `contact_id`, `nationbuilder_id`, `external_id`, `stripe_id`.\n- **mixed**: optional. When omitted the backend links each transaction row to the\n  contact created from the same CSV row via an internal `__row__` mechanism.\n  When provided, the named field is used for contact resolution (same as transactions).\n- **contacts**: unused.\n", example: contact_id}, linked_contact_import_id: {type: string, description: "For mixed imports: links the transaction processing phase back to this\nsame import so the watcher knows when contacts are done.\n", example: 722e168e-390e-4259-8206-dd2bba1f44b8}, transaction_import_id: {type: string, description: "The UUID of the transaction_import_job in the import-transaction service.\nWritten at enqueue time for `import_kind=\"transactions\"`.\nWritten at enqueue time (with status `waiting_for_contacts`) for `import_kind=\"mixed\"`,\nthen updated once the watcher triggers transaction processing.\nWhen present, `GET /imports/{id}` also returns a `tx_job` object with live progress.\n", example: ae297c10-ab2d-4657-916e-84d26786d80e}, column_indexes: {type: object, additionalProperties: true, description: "The saved column mapping (JSONB). Format depends on import_kind:\n- Contacts: `{ \"csv_col\": numeric_index }`\n- Transactions: `{ \"csv_col\": \"system.field.name\" }`\n- Mixed: both formats coexist\n", example: {contact_id: contact_id, montant: transaction.amount, monnaie: transaction.currency}}, csv_settings_defined_at: {type: string, format: date-time, nullable: true}, mapping_defined_at: {type: string, format: date-time, nullable: true}, total_created: {type: integer, description: 'Number of contacts/transactions successfully created'}, total_invalid_lines: {type: integer, description: 'Number of rows that failed'}, total_already_exists: {type: integer, description: 'Number of duplicate contacts found (contacts only)'}, total_errors: {type: integer}, total_on_hold_profils: {type: integer}, archived_at: {type: string, format: date-time, nullable: true, description: 'Set when the import is soft-deleted'}}}}}, example: {import: {id: 722e168e-390e-4259-8206-dd2bba1f44b8, file_name: /uploads/722e168e.csv, original_file_name: my_transactions.csv, import_kind: "", matching_field: "", status: created, created_at: '2026-05-17T10:00:00Z'}}}}}}
        get:
            tags: ['Unified Import']
            summary: 'List all imports'
            description: "Returns all imports for the authenticated group, regardless of import_kind.\nSupports pagination and optional archived filter.\n"
            operationId: listImports
            parameters: [{name: limit, in: query, schema: {type: integer, default: 20}}, {name: offset, in: query, schema: {type: integer, default: 0}}, {name: archived, in: query, schema: {type: boolean, default: false}}]
            responses: {'200': {description: 'List of imports', content: {application/json: {schema: {type: object, properties: {imports: {type: array, items: {type: object, description: "A unified import record from the `imports` table.\nUsed for contacts, transactions, and mixed imports alike.\n\nThe `import_kind` field determines how the system processes this import:\n- `\"\"` or `\"contacts\"` — contact import (legacy behavior, processed by importfileworker)\n- `\"transactions\"` — transaction-only import (processed by import-transaction service)\n- `\"mixed\"` — contacts first, then transactions automatically triggered after\n\nStatus tracking is done via the `import_statuses` table (see GET /imports/{id}/statuses).\n", properties: {id: {type: string, format: uuid, example: 722e168e-390e-4259-8206-dd2bba1f44b8}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}, customer_account_id: {type: integer, description: 'The group/organization this import belongs to'}, file_name: {type: string, description: 'Server-side file path', example: /uploads/722e168e.csv}, original_file_name: {type: string, description: 'Original filename as uploaded by the user', example: my_transactions_2026.csv}, name: {type: string, description: 'User-facing import name (editable)', example: 'Import Mai 2026'}, import_kind: {type: string, description: "Determined at the mapping step. Empty string means legacy contacts import.\n", enum: ["", contacts, transactions, mixed], example: transactions}, matching_field: {type: string, description: "The contact field used to link transaction rows to existing contacts.\n- **transactions**: required. One of `contact_id`, `nationbuilder_id`, `external_id`, `stripe_id`.\n- **mixed**: optional. When omitted the backend links each transaction row to the\n  contact created from the same CSV row via an internal `__row__` mechanism.\n  When provided, the named field is used for contact resolution (same as transactions).\n- **contacts**: unused.\n", example: contact_id}, linked_contact_import_id: {type: string, description: "For mixed imports: links the transaction processing phase back to this\nsame import so the watcher knows when contacts are done.\n", example: 722e168e-390e-4259-8206-dd2bba1f44b8}, transaction_import_id: {type: string, description: "The UUID of the transaction_import_job in the import-transaction service.\nWritten at enqueue time for `import_kind=\"transactions\"`.\nWritten at enqueue time (with status `waiting_for_contacts`) for `import_kind=\"mixed\"`,\nthen updated once the watcher triggers transaction processing.\nWhen present, `GET /imports/{id}` also returns a `tx_job` object with live progress.\n", example: ae297c10-ab2d-4657-916e-84d26786d80e}, column_indexes: {type: object, additionalProperties: true, description: "The saved column mapping (JSONB). Format depends on import_kind:\n- Contacts: `{ \"csv_col\": numeric_index }`\n- Transactions: `{ \"csv_col\": \"system.field.name\" }`\n- Mixed: both formats coexist\n", example: {contact_id: contact_id, montant: transaction.amount, monnaie: transaction.currency}}, csv_settings_defined_at: {type: string, format: date-time, nullable: true}, mapping_defined_at: {type: string, format: date-time, nullable: true}, total_created: {type: integer, description: 'Number of contacts/transactions successfully created'}, total_invalid_lines: {type: integer, description: 'Number of rows that failed'}, total_already_exists: {type: integer, description: 'Number of duplicate contacts found (contacts only)'}, total_errors: {type: integer}, total_on_hold_profils: {type: integer}, archived_at: {type: string, format: date-time, nullable: true, description: 'Set when the import is soft-deleted'}}}}, total: {type: integer}}}}}}}
    '/imports/{id}':
        parameters:
            - {name: id, in: path, required: true, schema: {type: string, format: uuid}, description: 'Import UUID'}
        get:
            tags: ['Unified Import']
            summary: 'Get import details / Poll status'
            description: "Returns the full state of an import including current status, totals, and metadata.\n\n**Poll this endpoint** after enqueue to track processing progress.\n\n\n**Contacts** (import_kind=\"\" or \"contacts\"):\n```\ncreated → file_config_defined → file_parsing_enqueued → file_parsing_wip →\nfile_parsing_done → search_conflicts_wip → search_conflicts_done →\nfinalization_process_wip → finalization_process_done\n```\n\n**Transactions** (import_kind=\"transactions\"):\n```\ncreated → file_config_defined → tx_validating → tx_validated →\ntx_publishing → tx_completed\n```\nOn error: `tx_validation_error` or `tx_error`\n\n**Mixed** (import_kind=\"mixed\"):\nThe contacts `status` field follows the contacts track and stays at\n`finalization_process_done` once contacts are done. Transaction progress\nis tracked separately via the `tx_job` field:\n```\ncontacts: finalization_process_done  (tx_job.tx_status: waiting_for_contacts → validating → completed)\n```\n\n\nWhen `transaction_import_id` is set and the import-transaction service is reachable,\nthe response includes a `tx_job` object with live transaction processing progress.\nThis field is absent for pure contact imports and when the service is unavailable.\n\nThe frontend should use `tx_job.tx_status` + `tx_job.tx_published_rows` /\n`tx_job.tx_total_rows` to show a transaction progress indicator independently\nof the contacts `status` field.\n"
            operationId: getImport
            responses: {'200': {description: 'Import details', content: {application/json: {schema: {type: object, properties: {id: {type: string, format: uuid}, status: {type: string, description: 'Current status label (contacts track for mixed imports)', example: finalization_process_done}, import_kind: {type: string, enum: ["", contacts, transactions, mixed]}, matching_field: {type: string}, transaction_import_id: {type: string, description: 'UUID of the linked transaction_import_job. Set after enqueue for transactions/mixed.', example: ae297c10-ab2d-4657-916e-84d26786d80e}, tx_job: {type: object, description: "Live progress snapshot of the linked `transaction_import_job`.\nIncluded in `GET /imports/{id}` when `transaction_import_id` is set and the\nimport-transaction service is reachable. Absent for pure contact imports.\n\nThe frontend can use these fields to show a secondary progress indicator\nalongside the contacts status (which stays at `finalization_process_done`\nfor mixed imports).\n", properties: {tx_status: {type: string, description: 'Current processing status of the transaction import job.', enum: [uploaded, validating, validated, publishing, completed, failed, waiting_for_contacts, expired], example: completed}, tx_total_rows: {type: integer, description: 'Total number of rows in the transaction file.', example: 150}, tx_published_rows: {type: integer, description: 'Rows successfully published to the consumer queue.', example: 148}, tx_error_rows: {type: integer, description: 'Rows that failed validation or publishing.', example: 2}, tx_failure_reason: {type: string, description: 'Top-level failure message when `tx_status` is `failed` or `expired`. Omitted otherwise.', example: '1 error(s) found — nothing was published (all-or-nothing)'}}}, counters: {type: object, description: 'Processing counters (total_created, total_invalid_lines, etc.)', additionalProperties: {type: integer}}}}, example: {id: 722e168e-390e-4259-8206-dd2bba1f44b8, status: finalization_process_done, import_kind: mixed, matching_field: contact_id, transaction_import_id: ae297c10-ab2d-4657-916e-84d26786d80e, tx_job: {tx_status: completed, tx_total_rows: 150, tx_published_rows: 150, tx_error_rows: 0}, counters: {total_created: 45, total_invalid_lines: 0, total_already_exists: 3, total_errors: 0}}}}}}
        delete:
            tags: ['Unified Import']
            summary: 'Delete (archive) an import'
            description: "Soft-deletes an import. Works for all import kinds.\nThe import is marked as archived and no longer appears in the default list.\n"
            operationId: deleteImport
            responses: {'202': {description: Deleted}}
    '/imports/{id}/columns':
        get:
            tags: ['Unified Import']
            summary: 'Get detected columns from the file'
            description: "Returns the raw column headers detected in the uploaded file.\nThis is kind-agnostic — just reads the first row of the CSV/XLSX.\n\nThe frontend uses this list to present the mapping UI, pairing file columns\nwith system columns (from `/import/transactions/available-columns` for transactions,\nor the known contact fields for contacts).\n\n**No import_kind needed** — this simply reads the file header.\n"
            operationId: getImportColumns
            parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}, {name: separator, in: query, description: 'CSV separator character (e.g. `;`, `,`). URL-encoded.', schema: {type: string}}, {name: columns_idx, in: query, description: 'Row index to use as column headers (0-based, default 0)', schema: {type: integer, default: 0}}, {name: comment, in: query, description: 'Comment character to ignore lines (e.g. `#`)', schema: {type: string}}]
            responses: {'200': {description: 'Array of column header strings', content: {application/json: {schema: {type: array, items: {type: string}}, example: [contact_id, nationbuilder_id, email, firstname, lastname, montant, monnaie, type_paiement]}}}}
    '/imports/{id}/file-preview':
        get:
            tags: ['Unified Import']
            summary: 'Preview raw file contents'
            description: "Returns the first 25 lines of the uploaded file for visual verification.\nKind-agnostic — works the same for contacts, transactions, or mixed files.\n"
            operationId: previewImportFile
            parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}, {name: separator, in: query, description: 'CSV separator character (e.g. `,`, `;`)', schema: {type: string}}, {name: comment, in: query, description: 'Comment character to ignore lines (e.g. `#`)', schema: {type: string}}, {name: start_read_at, in: query, description: '1-based line number where data starts', schema: {type: integer, default: 1}}]
            responses: {'200': {description: 'File preview (raw lines)'}}
    '/imports/{id}/csv-settings':
        post:
            tags: ['Unified Import']
            summary: 'Save CSV parsing settings'
            description: "Configures how the CSV file should be parsed: separator, comment character,\nstart row, country (for number formatting). Must be called before mapping.\n"
            operationId: saveImportCsvSettings
            parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}]
            requestBody: {required: true, content: {application/x-www-form-urlencoded: {schema: {type: object, required: [start_read_at, separator], properties: {start_read_at: {type: integer, description: '1-based line number where data starts', example: 1}, separator: {type: string, description: 'Column separator character', example: ','}, comment: {type: string, description: 'Lines starting with this character are ignored', example: '#'}, country: {type: string, description: 'Country code for locale-specific parsing (e.g. FR for comma decimals)', example: FR}}}}}}
            responses: {'201': {description: 'Settings saved'}}
    '/imports/{id}/mapping':
        post:
            tags: ['Unified Import', 'Import Kind Detection']
            summary: 'Save column mapping (determines import_kind)'
            description: "Saves the column mapping and **determines the import kind** based on the content.\n\nThis is the KEY endpoint where the system decides how to process the file.\n\n\nA flat JSON object. Two special top-level keys are extracted before saving:\n- `matching_field` — which contact field links rows to existing contacts\n- `import_kind` — explicit override (optional, see auto-detection)\n\nEverything else is the actual column mapping.\n\n\nDetection is based on what the mapping **values and keys** look like — not on `matching_field`.\n\n| Mapping contains… | Detected kind |\n|---|---|\n| Only numeric values on non-matching-field, non-namespaced keys | `\"\"` — contacts |\n| Only namespaced tx/donation/membership strings (or their numeric equivalents) | `\"transactions\"` |\n| Both contact columns (numeric, non-matching-field keys) AND tx columns (namespaced) | `\"mixed\"` |\n\n**Matching-field columns** (`contact_id`, `nationbuilder_id`, `external_id`, `stripe_id`)\nare treated as neutral — they do NOT trigger contact-column detection even when numeric.\n\n\n```json\n{ \"first_name\": 0, \"last_name\": 1, \"email\": 2, \"phone\": 3 }\n```\n→ All keys are contact fields with numeric indexes. Detected as contacts.\n\n```json\n{\n  \"matching_field\": \"stripe_id\",\n  \"stripe_id\": 0,\n  \"transaction.type\":                \"transaction.type\",\n  \"transaction.amount\":              \"transaction.amount\",\n  \"transaction.currency\":            \"transaction.currency\",\n  \"transaction.date\":                \"transaction.date\",\n  \"transaction.payment_method_kind\": \"transaction.payment_method_kind\",\n  \"donation.price_id\":               \"donation.price_id\"\n}\n```\n→ `stripe_id` is a matching field (neutral), all other non-neutral values are namespaced tx fields.\nDetected as `\"transactions\"`. `matching_field` is **required** — omitting it returns 422.\n\n> **Key matching is case-insensitive.** The object keys (CSV column headers) are matched\n> against the actual file headers without regard to case or surrounding whitespace.\n> For example, `\"Date de paiement\"` in the mapping will match the column `\"date de paiement\"`\n> in the CSV. Unmapped columns are blanked out (strict mode).\n\n```json\n{\n  \"first_name\": 0,\n  \"last_name\":  1,\n  \"email\":      2,\n  \"transaction.type\":                \"transaction.type\",\n  \"transaction.amount\":              \"transaction.amount\",\n  \"transaction.currency\":            \"transaction.currency\",\n  \"transaction.date\":                \"transaction.date\",\n  \"transaction.payment_method_kind\": \"transaction.payment_method_kind\",\n  \"donation.price_id\":               \"donation.price_id\"\n}\n```\n→ Contact columns (`first_name`, `last_name`, `email`) are numeric, non-matching-field keys\n→ `hasContactCols=true`. Namespaced tx strings → `hasTxCols=true`. Detected as `\"mixed\"`.\n**`matching_field` is omitted** — the backend links each transaction row to its contact\nvia an internal row-position mechanism (`__row__` column injected at enqueue time).\nDo **not** add a `__row__` column manually.\n\n> **`start_read_at=1` is required for mixed imports.** The header row must be skipped\n> so that internal row numbering starts at 1 for the first data row. Using `start_read_at=0`\n> will cause row-contact mismatch.\n\n```json\n{\n  \"matching_field\": \"stripe_id\",\n  \"first_name\": 0,\n  \"last_name\":  1,\n  \"email\":      2,\n  \"stripe_id\":  3,\n  \"transaction.amount\":  \"transaction.amount\",\n  \"transaction.date\":    \"transaction.date\",\n  \"donation.price_id\":   \"donation.price_id\"\n}\n```\n→ Same detection as above (contact columns + tx columns), but `matching_field` is set.\nContact resolution uses `stripe_id` instead of the row-position mechanism.\n\n\nIf `import_kind` resolves to `\"transactions\"` and `matching_field` is missing →\n**422 Unprocessable Entity** with message \"matching_field is required for transaction imports\".\nThis error does **not** apply to mixed imports — `matching_field` is optional there.\n"
            operationId: saveImportMapping
            parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}]
            requestBody: {required: true, content: {application/json: {schema: {type: object, additionalProperties: true, description: "Flat object. Special keys: `matching_field`, `import_kind`.\nRest is the column mapping.\n"}}}}
            responses: {'201': {description: 'Mapping saved, import_kind determined', content: {application/json: {schema: {type: object, description: "A unified import record from the `imports` table.\nUsed for contacts, transactions, and mixed imports alike.\n\nThe `import_kind` field determines how the system processes this import:\n- `\"\"` or `\"contacts\"` — contact import (legacy behavior, processed by importfileworker)\n- `\"transactions\"` — transaction-only import (processed by import-transaction service)\n- `\"mixed\"` — contacts first, then transactions automatically triggered after\n\nStatus tracking is done via the `import_statuses` table (see GET /imports/{id}/statuses).\n", properties: {id: {type: string, format: uuid, example: 722e168e-390e-4259-8206-dd2bba1f44b8}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}, customer_account_id: {type: integer, description: 'The group/organization this import belongs to'}, file_name: {type: string, description: 'Server-side file path', example: /uploads/722e168e.csv}, original_file_name: {type: string, description: 'Original filename as uploaded by the user', example: my_transactions_2026.csv}, name: {type: string, description: 'User-facing import name (editable)', example: 'Import Mai 2026'}, import_kind: {type: string, description: "Determined at the mapping step. Empty string means legacy contacts import.\n", enum: ["", contacts, transactions, mixed], example: transactions}, matching_field: {type: string, description: "The contact field used to link transaction rows to existing contacts.\n- **transactions**: required. One of `contact_id`, `nationbuilder_id`, `external_id`, `stripe_id`.\n- **mixed**: optional. When omitted the backend links each transaction row to the\n  contact created from the same CSV row via an internal `__row__` mechanism.\n  When provided, the named field is used for contact resolution (same as transactions).\n- **contacts**: unused.\n", example: contact_id}, linked_contact_import_id: {type: string, description: "For mixed imports: links the transaction processing phase back to this\nsame import so the watcher knows when contacts are done.\n", example: 722e168e-390e-4259-8206-dd2bba1f44b8}, transaction_import_id: {type: string, description: "The UUID of the transaction_import_job in the import-transaction service.\nWritten at enqueue time for `import_kind=\"transactions\"`.\nWritten at enqueue time (with status `waiting_for_contacts`) for `import_kind=\"mixed\"`,\nthen updated once the watcher triggers transaction processing.\nWhen present, `GET /imports/{id}` also returns a `tx_job` object with live progress.\n", example: ae297c10-ab2d-4657-916e-84d26786d80e}, column_indexes: {type: object, additionalProperties: true, description: "The saved column mapping (JSONB). Format depends on import_kind:\n- Contacts: `{ \"csv_col\": numeric_index }`\n- Transactions: `{ \"csv_col\": \"system.field.name\" }`\n- Mixed: both formats coexist\n", example: {contact_id: contact_id, montant: transaction.amount, monnaie: transaction.currency}}, csv_settings_defined_at: {type: string, format: date-time, nullable: true}, mapping_defined_at: {type: string, format: date-time, nullable: true}, total_created: {type: integer, description: 'Number of contacts/transactions successfully created'}, total_invalid_lines: {type: integer, description: 'Number of rows that failed'}, total_already_exists: {type: integer, description: 'Number of duplicate contacts found (contacts only)'}, total_errors: {type: integer}, total_on_hold_profils: {type: integer}, archived_at: {type: string, format: date-time, nullable: true, description: 'Set when the import is soft-deleted'}}}, examples: {transactions: {summary: 'Transaction-only import detected', value: {id: 722e168e-390e-4259-8206-dd2bba1f44b8, import_kind: transactions, matching_field: stripe_id, column_indexes: {stripe_id: 0, transaction.type: transaction.type, transaction.amount: transaction.amount, transaction.currency: transaction.currency, transaction.date: transaction.date, transaction.payment_method_kind: transaction.payment_method_kind, donation.price_id: donation.price_id}, mapping_defined_at: '2026-05-31T10:01:00Z'}}, mixed_no_matching_field: {summary: 'Mixed import detected — no matching_field', value: {id: c7917ddb-548b-4b28-b96d-ac950bbe611e, import_kind: mixed, matching_field: "", column_indexes: {first_name: 0, last_name: 1, email: 2, transaction.type: transaction.type, transaction.amount: transaction.amount, transaction.currency: transaction.currency, transaction.date: transaction.date, transaction.payment_method_kind: transaction.payment_method_kind, donation.price_id: donation.price_id}, mapping_defined_at: '2026-05-31T10:01:00Z'}}}}}}, '422': {description: 'matching_field required for transaction imports', content: {text/plain: {schema: {type: string}, example: 'matching_field is required for transaction imports'}}}}
    '/imports/{id}/validate':
        post:
            tags: ['Unified Import', 'Import Hub']
            summary: 'Dry-run validation (dispatched by import_kind)'
            description: "Validates the import before enqueue. **Behavior depends on import_kind:**\n\n---\n\n\n**No-op.** Returns `{ valid: true, message: \"contact import ready\" }`.\nContact validation happens during actual processing by importfileworker.\n\n---\n\n\nFull dry-run via the **import-transaction** RPC service.\n\n**What it checks:**\n- Required fields: `transaction.type`, `transaction.amount`, `transaction.date`\n- `transaction.payment_method_kind` presence (must be non-empty; unknown values are **not** rejected — they are auto-added to the group's `transaction_settings` by the consumer at processing time)\n- Contact resolution via `matching_field` column (falls back to other identifier columns if the primary field is empty on a given row)\n- Currency (CSV value or group's `transaction_settings.currency`)\n- Transaction status lookup (`transaction.status_id` or `transaction.status`)\n- Donation price resolution (`donation.price_id` or `donation.price_name`)\n- Membership price resolution (`membership.price_id` or `membership.price_name`)\n- Batch resolution (`transaction.batch_id` or `transaction.batch_display_name`)\n- Code campaign existence (`transaction.code_campaign`)\n- Custom amount validation (only when price has `custom_amount` enabled)\n\n**Transaction settings defaults** — Before validation, the group's `transaction_settings`\nare applied to any row missing: `transaction.currency`, `transaction.status_id`,\n`donation.price_id` (for donations), or `membership.price_id` (for memberships).\nValidation errors about missing currency/prices only occur when no CSV value AND\nno group default is configured.\n\n**Error enrichment:** When a lookup fails (price, status, batch, code_campaign),\nthe error includes `allowed_values` — the complete list of valid options for\nthis group. Frontend can use this for dropdowns/autocomplete correction UI.\n\n---\n\n\nValidates transaction fields only (contact resolution is **skipped** — contacts\ndon't exist yet). The backend injects the synthetic `__row__` column into the\nfile before validation so the parser accepts rows without a matching field.\nThis step does **not** write any status to the database.\n\n---\n\n\nFor transactions: if ANY row fails validation, **nothing will be published**\nwhen you call enqueue. Fix the errors and re-validate.\n\n---\n\n\nEach element in `errors[]`:\n\n```json\n{\n  \"row\": 6,\n  \"code\": \"DONATION_PRICE_NOT_FOUND\",\n  \"field\": \"donation.price_name\",\n  \"error\": \"donation.price_name \\\"Don annuel\\\": no matching donation price\",\n  \"value\": \"Don annuel\",\n  \"allowed_values\": [\"Don mensuel\", \"Don annuel 100€\", \"Don libre\"]\n}\n```\n\n- `row` — 1-based CSV row number (header = row 1, first data row = row 2). `0` = file-level error\n- `code` — machine-readable error code (see table below); use this for frontend behaviour mapping\n- `field` — namespaced field that caused the error (omitted for multi-field errors)\n- `error` — human-readable error string (see tables below)\n- `value` — the raw CSV value that triggered the error\n- `allowed_values` — populated only for DB-lookup errors; list of valid values for this group\n\n\nFor each row the transaction type must be determinable via:\n\n```\ntransaction.type  OR  ( donation_price XOR membership_price )\n```\n\nWhere `donation_price = (donation.price_id present AND valid) OR (donation.price_name present AND resolves)`\nand the same for `membership_price`.\n\n- **Single price present** (only donation OR only membership): the type is inferred from it.\n  If `transaction.type` is also given it must match — otherwise `AMBIGUOUS_TYPE`.\n- **Both prices present**: `transaction.type` is required to pick which one to use — otherwise `AMBIGUOUS_TYPE`.\n- **No price present**: `transaction.type` must be explicit; the group's default price from\n  `transaction_settings` is applied for that type — `DONATION/MEMBERSHIP_PRICE_REQUIRED` if no default.\n- If `donation.price_id` AND `donation.price_name` are both given they must resolve to the same\n  record — otherwise `DONATION_PRICE_NOT_FOUND`. Same rule for membership.\n\n---\n\n\nMultiple parse failures on the same row are collected — no early exit on first error.\nRow `0` means a file-level error that aborts validation before any row is processed.\n\n| `code` | `field` | `error` pattern | Trigger | `allowed_values` |\n|---|---|---|---|---|\n| `MISSING_REQUIRED_COLUMN` | *(varies)* | `missing required column: {col}` | Required column absent from file header (row=0) | — |\n| `MISSING_MATCHING_FIELD` | `matching_field` | `file must include at least one matching field column: …` | No matching field column — tx-only imports only (row=0) | — |\n| `INVALID_INTEGER` | `contact_id` | `contact_id: invalid integer \"{value}\"` | Non-numeric value | — |\n| `INVALID_INTEGER` | `nationbuilder_id` | `nationbuilder_id: invalid integer \"{value}\"` | Non-numeric value | — |\n| `INVALID_INTEGER` | `transaction.amount` | `transaction.amount: invalid integer \"{value}\"` | Non-integer | — |\n| `INVALID_INTEGER` | `transaction.status_id` | `transaction.status_id: invalid integer \"{value}\"` | Non-integer | — |\n| `INVALID_INTEGER` | `transaction.reimbursed_amount` | `transaction.reimbursed_amount: invalid integer \"{value}\"` | Non-integer | — |\n| `INVALID_INTEGER` | `transaction.unpaid_amount` | `transaction.unpaid_amount: invalid integer \"{value}\"` | Non-integer | — |\n| `INVALID_INTEGER` | `transaction.batch_id` | `transaction.batch_id: invalid integer \"{value}\"` | Non-integer | — |\n| `INVALID_INTEGER` | `donation.price_id` | `donation.price_id: invalid integer \"{value}\"` | Non-integer | — |\n| `INVALID_INTEGER` | `donation.amount` | `donation.amount: invalid integer \"{value}\"` | Non-integer | — |\n| `INVALID_INTEGER` | `membership.price_id` | `membership.price_id: invalid integer \"{value}\"` | Non-integer | — |\n| `INVALID_INTEGER` | `membership.amount` | `membership.amount: invalid integer \"{value}\"` | Non-integer | — |\n| `AMOUNT_REQUIRED` | `transaction.amount` | `transaction.amount: is required` | Empty amount | — |\n| `INVALID_DATE` | `transaction.date` | `transaction.date: {parse error details}` | Unparseable date; accepted: `YYYY-MM-DD`, `DD/MM/YYYY`, `DD-MM-YYYY`, `MM/DD/YYYY` | — |\n| `DATE_REQUIRED` | `transaction.date` | `transaction.date: is required` | Empty date | — |\n| `INVALID_TYPE` | `transaction.type` | `transaction.type: must be 'donation' or 'membership', got \"{value}\"` | Value not `donation` or `membership` | — |\n| `AMBIGUOUS_TYPE` | `transaction.type` | `transaction.type: ambiguous — both donation and membership prices provided; specify type explicitly` | Both prices present without explicit type | — |\n| `AMBIGUOUS_TYPE` | `transaction.type` | `donation/membership price provided but transaction.type is \"{value}\"` | Explicit type contradicts the single price present | — |\n| `TYPE_REQUIRED` | `transaction.type` | `transaction.type: required unless donation_price or membership_price is specified` | No type and no price to infer from | — |\n| `NO_MATCHING_FIELD` | *(omitted)* | `no matching field provided (need contact_id, nationbuilder_id, external_id, or stripe_id)` | All matching field cells empty for this row | — |\n| `PAYMENT_METHOD_REQUIRED` | `transaction.payment_method_kind` | `transaction.payment_method_kind is required` | Empty | — |\n| `CURRENCY_REQUIRED` | `transaction.currency` | `transaction.currency is required and no default currency is configured in transaction_settings` | Empty and no default in `transaction_settings` | — |\n| `STATUS_NOT_FOUND` | `transaction.status_id` | `transaction.status_id {id} does not exist for this group` | ID not in group's statuses | `[\"1 (paid)\", \"2 (reimbursed)\", …]` |\n| `STATUS_NOT_FOUND` | `transaction.status` | `transaction.status \"{value}\" is not a valid status kind` | Kind not in group's statuses | `[\"paid\", \"reimbursed\", …]` |\n| `REIMBURSED_AMOUNT_INVALID_STATUS` | `transaction.reimbursed_amount` | `transaction.reimbursed_amount is only valid for 'reimbursed' status, got status kind \"{kind}\"` | Wrong status for reimbursed_amount | — |\n| `BATCH_NOT_FOUND` | `transaction.batch_id` | `transaction.batch_id {id} does not exist for this group` | ID not in group's open batches | `[\"1 (Batch Jan 2024)\", …]` (max 50) |\n| `BATCH_NOT_FOUND` | `transaction.batch_display_name` | `transaction.batch_display_name \"{value}\": no matching batch` | Name not in group's open batches | `[\"Batch Jan 2024\", …]` (max 50) |\n| `DONATION_PRICE_REQUIRED` | `donation.price_id` | `donation.price_id or donation.price_name: required when type=donation` | `type=donation`, no price provided, no `transaction_settings.default_donation_price_id` | `donation_prices` |\n| `DONATION_PRICE_NOT_FOUND` | `donation.price_id` | `donation.price_id {id} not found` | ID not in group's prices | `[\"10 (Don libre)\", …]` |\n| `DONATION_PRICE_NOT_FOUND` | `donation.price_id` | `donation.price_id {id} and donation.price_name \"{name}\" refer to different prices` | ID and name resolve to different records | — |\n| `DONATION_PRICE_NOT_FOUND` | `donation.price_name` | `donation.price_name \"{value}\" not found` | Name not in group's prices | `[\"Don mensuel\", …]` |\n| `CUSTOM_AMOUNT_NOT_ALLOWED` | `donation.amount` | `donation.amount provided but donation price \"{name}\" (id={id}) does not allow custom_amount` | Price has `custom_amount=false` | — |\n| `MEMBERSHIP_PRICE_REQUIRED` | `membership.price_id` | `membership.price_id or membership.price_name: required when type=membership` | `type=membership`, no price provided, no `transaction_settings.default_membership_price_id` | `membership_prices` |\n| `MEMBERSHIP_PRICE_NOT_FOUND` | `membership.price_id` | `membership.price_id {id} not found` | ID not in group's prices | `[\"20 (Adhésion annuelle)\", …]` |\n| `MEMBERSHIP_PRICE_NOT_FOUND` | `membership.price_id` | `membership.price_id {id} and membership.price_name \"{name}\" refer to different prices` | ID and name resolve to different records | — |\n| `MEMBERSHIP_PRICE_NOT_FOUND` | `membership.price_name` | `membership.price_name \"{value}\" not found` | Name not in group's prices | `[\"Adhésion annuelle\", …]` |\n| `CUSTOM_AMOUNT_NOT_ALLOWED` | `membership.amount` | `membership.amount provided but membership price \"{name}\" (id={id}) does not allow custom_amount` | Price has `custom_amount=false` | — |\n| `CODE_CAMPAIGN_NOT_FOUND` | `transaction.code_campaign` | `transaction.code_campaign \"{value}\" does not exist for this group` | Code not in group's campaigns | `[\"ADH072023\", …]` (max 50) |\n| `CONTACT_NOT_FOUND` | *(matching field)* | `{field}: contact not found` | Contact not found by matching field value | — |\n| `CONTACT_NOT_RESOLVED` | *(omitted)* | `row {n}: contact not resolved — possible on-hold conflict or invalid contact` | Mixed import: no row→contact mapping available | — |\n"
            operationId: validateImport
            parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}]
            responses: {'200': {description: 'Validation result', content: {application/json: {schema: {oneOf: [{$ref: '#/components/schemas/TransactionValidateResponse'}, {$ref: '#/components/schemas/ContactValidateResponse'}]}, examples: {contacts: {summary: 'Contact import (always valid — no-op)', value: {valid: true, message: 'contact import ready'}}, transactions_valid: {summary: 'Transaction import — all rows valid', value: {valid_count: 150, total_rows: 150, error_count: 0, errors: []}}, transactions_errors: {summary: 'Transaction import — some rows have errors', value: {valid_count: 148, total_rows: 150, error_count: 2, errors: [{row: 6, field: donation.price_name, error: 'donation.price_name "Don annuel": no matching donation price', value: 'Don annuel', allowed_values: ['Don mensuel', 'Don annuel 100€', 'Don libre']}, {row: 42, field: transaction.amount, error: 'transaction.amount: invalid integer "-500"', value: '-500', allowed_values: []}]}}, file_level_error: {summary: 'File-level error (missing required column)', value: {valid_count: 0, total_rows: 0, error_count: 1, errors: [{row: 0, field: "", error: 'missing required column: transaction.amount', value: "", allowed_values: []}]}}}}}}, '503': {description: 'import-transaction service not available'}}
    '/imports/{id}/enqueue':
        post:
            tags: ['Unified Import', 'Import Hub']
            summary: 'Start import processing (dispatched by import_kind)'
            description: "Launches the actual import. This is the **final step** — after calling enqueue,\nthe import is in-flight. Poll `GET /imports/{id}` to track progress.\n\n---\n\n\n1. Validates status is `file_config_defined`\n2. Checks CSV settings + mapping are defined\n3. Updates status to `file_parsing_enqueued`\n4. Pushes import ID to Redis queue (`importjobs`)\n5. `importfileworker` picks it up → parses file → creates contacts\n6. Status: `file_parsing_wip` → `file_parsing_done` → `search_conflicts_*` → `finalization_*`\n7. Returns **201 Created**\n\n---\n\n\n1. Requires `matching_field` to be set (via mapping step)\n2. Reads the CSV file from disk\n3. Extracts mapping from the imports table\n4. Sends everything inline to import-transaction RPC service:\n   - Uploads file to S3\n   - Full validation (contacts resolution, prices, batches, etc.)\n   - Publishes valid rows to RabbitMQ lane queues (round-robin fairness)\n5. **Writes `transaction_import_id`** to the import record (UUID of the created job)\n6. `consumer-transactions` processes each message → creates transaction bundles\n7. Status: `tx_validating` → `tx_validated` → `tx_publishing` → `tx_completed`\n8. On error: `tx_validation_error` or `tx_error` with `failure_reason`\n9. Returns **202 Accepted**\n\n---\n\n\n1. Validates status is `file_config_defined` and CSV settings + mapping are defined\n2. Reads the file and injects a synthetic `__row__` column (1-indexed per data row)\n   into the copy sent to the transaction import job — **do not add this column manually**\n3. Creates a transaction import job (`status=waiting_for_contacts`) via RPC\n4. **Writes `transaction_import_id`** to the import record immediately\n5. Pushes the contact import to the Redis queue for processing first\n6. Once contacts finish, `searchconflictworker` has written a `(import_id, row_number, contact_id)`\n   mapping to `mixed_import_row_links`. The import-transaction watcher detects the Redis\n   `contacts:done` signal, reads the mapping table, and processes transactions — zero contact\n   RPC calls, guaranteed correct row→contact pairing\n7. The contacts `status` field stays at `finalization_process_done` — transaction progress\n   is tracked via `tx_job` in `GET /imports/{id}`\n8. Returns **201 Created**\n\nThe user clicks enqueue once. The system sequences contacts → transactions automatically.\nPoll `GET /imports/{id}` and watch both `status` (contacts) and `tx_job.tx_status` (transactions).\n\n> **Without `matching_field`:** row N's transaction always goes to the contact created\n> from row N, regardless of duplicates or deduplication merges.\n>\n> **With `matching_field`:** contact resolution uses the named field (same as tx-only).\n> The `__row__` injection is skipped in this case.\n\n---\n\n\nUse `GET /imports/{id}/statuses` to see the full timeline:\n```\nfile_parsing_enqueued   ← contact phase started\nfile_parsing_wip\nfile_parsing_done\nsearch_conflicts_wip\nfinalization_process_done  ← contacts done, tx watcher triggered\n```\nTransaction progress is only visible via `GET /imports/{id}` → `tx_job` fields.\nThe `import_statuses` table is intentionally NOT written during transaction processing\nfor mixed imports — it stays on the contacts track to avoid confusing the frontend.\n"
            operationId: enqueueImport
            parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}, {name: channel, in: query, description: 'Redis queue channel (0 or 1) for load balancing. Default 0.', schema: {type: integer, default: 0}}]
            responses: {'201': {description: 'Enqueued (contacts/mixed — pushed to Redis)'}, '202': {description: 'Accepted (transactions — RPC started)', content: {application/json: {schema: {type: object, properties: {status: {type: string, example: tx_validating}, message: {type: string, example: 'transaction import started'}}}}}}, '422': {description: 'Import not ready', content: {application/json: {examples: {wrong_status: {value: {status: error, message: 'import has invalid status "created", expected: "file_config_defined"'}}, missing_settings: {value: {status: error, message: 'import must have CSV settings and mapping defined'}}, missing_matching: {value: {status: error, message: 'matching_field is required for transaction imports'}}}}}}, '503': {description: 'import-transaction service not available (transactions/mixed only)'}}
    '/imports/{id}/invalid-lines':
        get:
            tags: ['Unified Import']
            summary: 'List invalid rows'
            description: "Returns rows that failed processing, with error details.\n\n- **Contacts**: populated by importfileworker during file parsing\n- **Transactions**: populated during validate/enqueue dry-run\n  (use the validate endpoint response for immediate feedback)\n"
            operationId: listImportInvalidLines
            parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}, {name: format, in: query, description: 'Set to `csv` to download as CSV with errors prepended', schema: {type: string, enum: [json, csv], default: json}}]
            responses: {'200': {description: 'Invalid rows'}}
    '/imports/{id}/statuses':
        get:
            tags: ['Unified Import']
            summary: 'Full status history (for debugging)'
            description: "Returns ALL status transitions for this import in chronological order.\n\nParticularly useful for **mixed imports** where both contact and transaction\nphases write to the same table. Each entry has a label, description, and timestamp.\n\n```json\n[\n  { \"label\": \"created\", \"description\": \"\", \"created_at\": \"2026-05-17T10:00:00Z\" },\n  { \"label\": \"file_config_defined\", \"description\": \"\", \"created_at\": \"2026-05-17T10:01:00Z\" },\n  { \"label\": \"file_parsing_enqueued\", \"description\": \"mixed import: contacts enqueued\", \"created_at\": \"2026-05-17T10:02:00Z\" },\n  { \"label\": \"file_parsing_wip\", \"description\": \"\", \"created_at\": \"2026-05-17T10:02:05Z\" },\n  { \"label\": \"file_parsing_done\", \"description\": \"created 45 contacts\", \"created_at\": \"2026-05-17T10:03:00Z\" },\n  { \"label\": \"tx_validating\", \"description\": \"triggered by contact import completion\", \"created_at\": \"2026-05-17T10:03:01Z\" },\n  { \"label\": \"tx_validated\", \"description\": \"150 rows valid, 0 errors\", \"created_at\": \"2026-05-17T10:03:10Z\" },\n  { \"label\": \"tx_publishing\", \"description\": \"\", \"created_at\": \"2026-05-17T10:03:11Z\" },\n  { \"label\": \"tx_completed\", \"description\": \"150 rows published\", \"created_at\": \"2026-05-17T10:04:00Z\" }\n]\n```\n\n```json\n[\n  { \"label\": \"created\", \"description\": \"\", \"created_at\": \"2026-05-17T10:00:00Z\" },\n  { \"label\": \"file_config_defined\", \"description\": \"\", \"created_at\": \"2026-05-17T10:01:00Z\" },\n  { \"label\": \"tx_validating\", \"description\": \"\", \"created_at\": \"2026-05-17T10:01:30Z\" },\n  { \"label\": \"tx_validation_error\", \"description\": \"2 error(s) found — nothing was published (all-or-nothing)\", \"created_at\": \"2026-05-17T10:01:35Z\" }\n]\n```\n"
            operationId: listImportStatuses
            parameters: [{name: id, in: path, required: true, schema: {type: string, format: uuid}}]
            responses: {'200': {description: 'Status history', content: {application/json: {schema: {type: array, items: {type: object, properties: {label: {type: string}, description: {type: string}, created_at: {type: string, format: date-time}}}}}}}}
components:
    securitySchemes:
        apiKey:
            type: http
            scheme: bearer
    schemas:
        Import:
            type: object
            description: "A unified import record from the `imports` table.\nUsed for contacts, transactions, and mixed imports alike.\n\nThe `import_kind` field determines how the system processes this import:\n- `\"\"` or `\"contacts\"` — contact import (legacy behavior, processed by importfileworker)\n- `\"transactions\"` — transaction-only import (processed by import-transaction service)\n- `\"mixed\"` — contacts first, then transactions automatically triggered after\n\nStatus tracking is done via the `import_statuses` table (see GET /imports/{id}/statuses).\n"
            properties: {id: {type: string, format: uuid, example: 722e168e-390e-4259-8206-dd2bba1f44b8}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}, customer_account_id: {type: integer, description: 'The group/organization this import belongs to'}, file_name: {type: string, description: 'Server-side file path', example: /uploads/722e168e.csv}, original_file_name: {type: string, description: 'Original filename as uploaded by the user', example: my_transactions_2026.csv}, name: {type: string, description: 'User-facing import name (editable)', example: 'Import Mai 2026'}, import_kind: {type: string, description: "Determined at the mapping step. Empty string means legacy contacts import.\n", enum: ["", contacts, transactions, mixed], example: transactions}, matching_field: {type: string, description: "The contact field used to link transaction rows to existing contacts.\n- **transactions**: required. One of `contact_id`, `nationbuilder_id`, `external_id`, `stripe_id`.\n- **mixed**: optional. When omitted the backend links each transaction row to the\n  contact created from the same CSV row via an internal `__row__` mechanism.\n  When provided, the named field is used for contact resolution (same as transactions).\n- **contacts**: unused.\n", example: contact_id}, linked_contact_import_id: {type: string, description: "For mixed imports: links the transaction processing phase back to this\nsame import so the watcher knows when contacts are done.\n", example: 722e168e-390e-4259-8206-dd2bba1f44b8}, transaction_import_id: {type: string, description: "The UUID of the transaction_import_job in the import-transaction service.\nWritten at enqueue time for `import_kind=\"transactions\"`.\nWritten at enqueue time (with status `waiting_for_contacts`) for `import_kind=\"mixed\"`,\nthen updated once the watcher triggers transaction processing.\nWhen present, `GET /imports/{id}` also returns a `tx_job` object with live progress.\n", example: ae297c10-ab2d-4657-916e-84d26786d80e}, column_indexes: {type: object, additionalProperties: true, description: "The saved column mapping (JSONB). Format depends on import_kind:\n- Contacts: `{ \"csv_col\": numeric_index }`\n- Transactions: `{ \"csv_col\": \"system.field.name\" }`\n- Mixed: both formats coexist\n", example: {contact_id: contact_id, montant: transaction.amount, monnaie: transaction.currency}}, csv_settings_defined_at: {type: string, format: date-time, nullable: true}, mapping_defined_at: {type: string, format: date-time, nullable: true}, total_created: {type: integer, description: 'Number of contacts/transactions successfully created'}, total_invalid_lines: {type: integer, description: 'Number of rows that failed'}, total_already_exists: {type: integer, description: 'Number of duplicate contacts found (contacts only)'}, total_errors: {type: integer}, total_on_hold_profils: {type: integer}, archived_at: {type: string, format: date-time, nullable: true, description: 'Set when the import is soft-deleted'}}
        TxJob:
            type: object
            description: "Live progress snapshot of the linked `transaction_import_job`.\nIncluded in `GET /imports/{id}` when `transaction_import_id` is set and the\nimport-transaction service is reachable. Absent for pure contact imports.\n\nThe frontend can use these fields to show a secondary progress indicator\nalongside the contacts status (which stays at `finalization_process_done`\nfor mixed imports).\n"
            properties: {tx_status: {type: string, description: 'Current processing status of the transaction import job.', enum: [uploaded, validating, validated, publishing, completed, failed, waiting_for_contacts, expired], example: completed}, tx_total_rows: {type: integer, description: 'Total number of rows in the transaction file.', example: 150}, tx_published_rows: {type: integer, description: 'Rows successfully published to the consumer queue.', example: 148}, tx_error_rows: {type: integer, description: 'Rows that failed validation or publishing.', example: 2}, tx_failure_reason: {type: string, description: 'Top-level failure message when `tx_status` is `failed` or `expired`. Omitted otherwise.', example: '1 error(s) found — nothing was published (all-or-nothing)'}}
        TransactionImportJob:
            type: object
            description: "Internal tracking record used by the import-transaction service.\nThis is a separate table (`transaction_import_jobs`) from the unified `imports` table.\nThe gateway bridges between them — the frontend only interacts with the unified Import model.\n"
            properties: {id: {type: string, format: uuid, example: ae297c10-ab2d-4657-916e-84d26786d80e}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}, group_id: {type: integer}, user_id: {type: integer}, filename: {type: string, example: transactions-2026.csv}, file_size: {type: integer, description: 'File size in bytes'}, status: {type: string, description: 'Internal processing status', enum: [uploaded, validating, validated, publishing, completed, failed, waiting_for_contacts]}, linked_contact_import_id: {type: string, description: "When set, this transaction import waits for the linked contact import to complete.\nThe `watchContactImportCompletion` goroutine polls Redis for a completion signal.\n", example: b1c2d3e4-f5a6-7890-abcd-ef1234567890}, matching_field: {type: string, example: contact_id}, file_path: {type: string, description: 'Local temp file path (cleared after S3 upload)'}, file_id: {type: integer, description: 'S3 file record ID (set after enqueue)'}, file_url: {type: string, example: 'https://materials.qomon.org/abc123/integration/565/file.csv'}, delimiter: {type: string, example: ','}, detected_columns: {type: array, items: {type: string}}, mapping: {type: object, additionalProperties: {type: string}, example: {montant: transaction.amount, monnaie: transaction.currency}}, total_rows: {type: integer}, valid_rows: {type: integer}, error_rows: {type: integer}, published_rows: {type: integer}, error_summary: {type: array, items: {$ref: '#/components/schemas/ImportRowError'}}, failure_reason: {type: string, example: '1 error(s) found — nothing was published (all-or-nothing)'}}
        TransactionImportRow:
            type: object
            description: 'A single row record from an import job (internal tracking)'
            properties: {id: {type: integer}, created_at: {type: string, format: date-time}, import_job_id: {type: string, format: uuid}, row_number: {type: integer, description: '1-based row number in the original CSV (header = 1, first data row = 2)'}, status: {type: string, enum: [valid, invalid, published, failed]}, contact_id: {type: integer, description: 'Resolved contact ID (0 if not found)'}, error: {type: string}}
        ImportRowError:
            type: object
            description: "Describes a single validation error for a specific row.\nWhen the error is a lookup failure, `allowed_values` contains all valid options\nso the frontend can present a correction UI.\nUse `code` for programmatic behaviour mapping; `error` is human-readable only.\n"
            properties: {row: {type: integer, description: '1-based CSV row number (0 = file-level error)', example: 6}, code: {type: string, description: "Machine-readable error code. See the validate endpoint description for the\nfull list of codes and their meanings.\n", example: DONATION_PRICE_NOT_FOUND}, field: {type: string, description: 'The namespaced field that caused the error', example: donation.price_name}, error: {type: string, description: 'Human-readable error message. Do not parse this string — use `code` instead.', example: 'donation.price_name "Don annuel": no matching donation price'}, value: {type: string, description: 'The invalid value from the CSV', example: 'Don annuel'}, allowed_values: {type: array, description: "All valid values for this field in this group.\nPresent when error is a lookup failure (price, status, batch, code_campaign).\n", items: {type: string}, example: ['Don mensuel', 'Don annuel 100€', 'Don libre']}}
        ColumnAnalysis:
            type: object
            description: 'Analysis of detected vs recognised columns (used internally by import-transaction)'
            properties: {detected: {type: array, items: {type: string}}, recognized: {type: array, items: {type: string}}, unknown: {type: array, items: {type: string}}, required_missing: {type: array, items: {type: string}}, has_enough_to_map: {type: boolean}}
        FilePreview:
            type: object
            properties: {header: {type: array, items: {type: string}}, rows: {type: array, items: {type: array, items: {type: string}}, description: 'Up to 25 data rows'}, delimiter: {type: string, example: ','}}
        ValidateResult:
            type: object
            description: 'Result of a transaction dry-run validation'
            properties: {valid_count: {type: integer, example: 150}, error_count: {type: integer, example: 2}, errors: {type: array, items: {$ref: '#/components/schemas/ImportRowError'}}}
        TransactionImportColumnDef:
            type: object
            description: "Describes a single mappable field for the transaction import mapping UI.\nReturned by GET /import/transactions/available-columns.\n"
            properties: {value: {type: string, description: 'Namespaced field key', example: transaction.amount}, label: {type: string, description: 'Display label (frontend will translate via i18n)', example: transaction.amount}, required: {type: boolean, description: 'Whether mapping this field is mandatory for a valid import'}, field_type: {type: string, enum: [string, integer, date, json, boolean], description: 'Expected data type'}}
