فهرست منبع

B3 CPR register fixes

EduLascala 4 هفته پیش
والد
کامیت
b53ac4d3d4
4فایلهای تغییر یافته به همراه667 افزوده شده و 0 حذف شده
  1. 110 0
      controllers/B3CprRegisterController.php
  2. 1 0
      public/index.php
  3. 291 0
      routes.md
  4. 265 0
      services/B3CprService.php

+ 110 - 0
controllers/B3CprRegisterController.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace Controllers;
+
+use Libs\ResponseLib;
+use Psr\Http\Message\ServerRequestInterface;
+use React\Http\Message\Response;
+use Services\B3CprService;
+use Models\CprModel;
+use Models\StatusModel;
+use Models\PaymentModel;
+
+class B3CprRegisterController
+{
+    private B3CprService $service;
+    private CprModel $cprModel;
+    private StatusModel $statusModel;
+    private PaymentModel $paymentModel;
+
+    public function __construct()
+    {
+        $this->service = new B3CprService();
+        $this->cprModel = new CprModel();
+        $this->statusModel = new StatusModel();
+        $this->paymentModel = new PaymentModel();
+    }
+
+    public function __invoke(ServerRequestInterface $request)
+    {
+        $body = json_decode((string)$request->getBody(), true);
+        if (!is_array($body)) {
+            return ResponseLib::sendFail('Invalid JSON body', [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        $token = $body['b3_access_token'] ?? ($body['access_token'] ?? null);
+        if (!$token) {
+            $b3Auth = $request->getHeaderLine('X-B3-Authorization') ?: '';
+            if (stripos($b3Auth, 'Bearer ') === 0) {
+                $token = trim(substr($b3Auth, 7));
+            }
+        }
+        if (!$token) {
+            $token = $request->getHeaderLine('X-B3-Access-Token') ?: null;
+        }
+
+        $cpr = $body['cpr'] ?? null;
+        if (!is_array($cpr)) {
+            $hasCprKeys = false;
+            foreach ($body as $k => $_) {
+                if (is_string($k) && substr($k, 0, 4) === 'cpr_') {
+                    $hasCprKeys = true;
+                    break;
+                }
+            }
+            if ($hasCprKeys) {
+                $cpr = $body;
+            }
+        }
+
+        if (!is_array($cpr)) {
+            return ResponseLib::sendFail('Missing CPR payload (array) in body as cpr', [], 'E_VALIDATE')->withStatus(400);
+        }
+
+        $userId = (int)($request->getAttribute('api_user_id') ?? 0);
+        if ($userId <= 0) {
+            return ResponseLib::sendFail('Authenticated user not found', [], 'E_VALIDATE')->withStatus(401);
+        }
+
+        $statusId = $this->statusModel->getIdByStatus('pending');
+        if ($statusId === null) {
+            return ResponseLib::sendFail('Pending status not found', [], 'E_DATABASE')->withStatus(500);
+        }
+
+        try {
+            $paymentExternalId = 'B3_DIRECT_' . time();
+            $paymentId = $this->paymentModel->create($paymentExternalId, $statusId, $userId);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to create payment record: ' . $e->getMessage(), [], 'E_DATABASE')->withStatus(500);
+        }
+
+        try {
+            $record = $this->cprModel->create($cpr, $statusId, $paymentId);
+        } catch (\InvalidArgumentException $e) {
+            return ResponseLib::sendFail($e->getMessage(), [], 'E_VALIDATE')->withStatus(400);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to create CPR: ' . $e->getMessage(), [], 'E_DATABASE')->withStatus(500);
+        }
+
+        try {
+            $payload = $this->service->mapToB3($cpr);
+            if (!$token) {
+                $token = $this->service->getAccessToken();
+            }
+            $result = $this->service->postCpr($token, $payload);
+        } catch (\Throwable $e) {
+            return ResponseLib::sendFail('Failed to send CPR to B3: ' . $e->getMessage(), [], 'E_EXTERNAL')->withStatus(502);
+        }
+
+        if (isset($result['error'])) {
+            return ResponseLib::sendFail('cURL error during B3 CPR request', ['error' => $result['error']], 'E_EXTERNAL')->withStatus(502);
+        }
+
+        $status = (int)($result['status'] ?? 200);
+        if (isset($result['json'])) {
+            return Response::json($result['json'])->withStatus($status ?: 200);
+        }
+
+        return Response::json(['raw' => $result['raw'] ?? null, 'status' => $status])->withStatus($status ?: 502);
+    }
+}

+ 1 - 0
public/index.php

@@ -62,5 +62,6 @@ $app->post('/cpr/create', $authJwt, \Controllers\RegisterCprController::class);
 $app->post('/token/get', $authJwt, \Controllers\TokenGetController::class);
 
 $app->post('/b3/token', \Controllers\B3TokenController::class);
+$app->post('/b3/cpr/register', $authJwt, \Controllers\B3CprRegisterController::class);
 
 $app->run();

+ 291 - 0
routes.md

@@ -741,3 +741,294 @@ curl --location 'http://localhost:8000/token/get' \
   "data": []
 }
 ```
+
+---
+
+## 11. B3 — CPR Register
+
+Receives a CPR payload in the **flat** format used by our database, saves it in the `cpr` table, converts it to the B3 nested format, automatically obtains a B3 access token (mTLS), and sends it to the B3 CPR registration endpoint.
+
+### **Endpoint**
+
+`POST /b3/cpr/register`
+
+### **Headers**
+
+`Content-Type: application/json`
+
+`Authorization: Bearer <JWT>`
+
+### **Request Body (JSON)**
+
+```json
+{
+  "cpr": {
+    "cpr_type_code": "P",
+    "cpr_otc_register_account_code": "64359.40-5",
+    "cpr_otc_payment_agent_account_code": "64359.40-5",
+    "cpr_otc_custodian_account_code": "64359.00-3",
+    "cpr_internal_control_number": "NA",
+    "cpr_electronic_emission_indicator": "SIM",
+    "cpr_isin_code": "NA",
+    "cpr_issue_date": "2025-11-28",
+    "cpr_maturity_date": "2026-09-30",
+    "cpr_issue_quantity": 1,
+    "cpr_issue_value": 1710000.00,
+    "cpr_issue_financial_value": 1710000.00,
+    "cpr_unit_value": 1710000.00,
+    "cpr_reference_date": "2025-12-09",
+    "cpr_profitability_start_date": "2025-12-09",
+    "cpr_automatic_expiration_indicator": "NÃO",
+    "cpr_collateral_type_code": "Penhor",
+    "cpr_collateral_type_name": "NA",
+    "cpr_constitution_process_indicator": "SIM",
+    "cpr_otc_bondsman_account_code": "NA",
+    "cpr_collaterals_document_number": "NA",
+    "cpr_product_name": "MILHO",
+    "cpr_product_class_name": "MILHO (EM GRAOS)",
+    "cpr_product_harvest": "2026/2026",
+    "cpr_product_description": "Milho brasileiro em grãos, tipo exportação.",
+    "cpr_product_quantity": 2700000,
+    "cpr_measure_unit_name": "QUILO",
+    "cpr_packaging_way_name": "SACA (60 KG)",
+    "cpr_product_status_code": "A PRODUZIR",
+    "cpr_production_type_code": "PROPRIA",
+    "cpr_issuer_name": "MARCELO VINCENZI",
+    "cpr_finality_code": "NA",
+    "cpr_ipoc_code": "NA",
+    "cpr_calculation_type_code": "NA",
+    "cpr_initial_exchange_value": "NA",
+    "cpr_fixing_type_code": "NA",
+    "cpr_data_source_type_code": "NA",
+    "cpr_adjustment_frequency_type_code": "NA",
+    "cpr_adjustment_pro_rata_type_code": "NA",
+    "cpr_adjustment_type_code": "NA",
+    "cpr_creditor_name": "TOO EASY TRADING LTDA",
+    "cpr_ballast_type_code": "NA",
+    "cpr_lot_number": "NA",
+    "cpr_ballast_quantity": "NA",
+    "cpr_currency_code": "NA",
+    "cpr_transaction_identification": "NA",
+    "cpr_additional_text": "NA",
+    "cpr_number": "NA",
+    "cpr_contract_number": "NA",
+    "cpr_event_type_code": "NA",
+    "cpr_event_original_date": "NA",
+    "cpr_unit_price_value": 1710000.00,
+    "cpr_interest_unit_price_value": 0.00,
+    "cpr_residual_value": "NA",
+    "cpr_amortization_percentage": "NA",
+    "cpr_event_quantity": "NA",
+    "cpr_production_place_name": "FAZENDA CORAÇÃO DE MARIA",
+    "cpr_property_registration_number": "14406",
+    "cpr_notary_name": "NA",
+    "cpr_total_production_area_in_hectares_number": "NA",
+    "cpr_total_area_in_hectares_number": 670,
+    "cpr_car_code": "NA",
+    "cpr_latitude_code": "NA",
+    "cpr_longitude_code": "NA",
+    "cpr_zip_code": "78560000",
+    "cpr_green_cpr_indicator": "NA",
+    "cpr_green_cpr_certificate_name": "NA",
+    "cpr_green_cpr_certificate_cnpj_number": "NA",
+    "cpr_green_cpr_georeferencing_description": "NA",
+    "cpr_green_cpr_declaration_indicator": "NA",
+    "cpr_document_deadline_days_number": "NA",
+    "cpr_place_name": "NA",
+    "cpr_guarantee_limit_type_code": "NA",
+    "cpr_mother_code": "NA",
+    "cpr_issuers_person_type_acronym": "PF",
+    "cpr_issuers_state_acronym": "MT",
+    "cpr_issuers_city_name": "SINOP",
+    "cpr_issuers_ibge_code": "NA",
+    "cpr_issuer_legal_nature_code": "02",
+    "cpr_otc_favored_account_code": "64359.00-3",
+    "cpr_issuers_document_number": "867.308.271-49",
+    "cpr_deposit_person_type_acronym": "PF",
+    "cpr_self_number": "NA",
+    "cpr_settlement_modality_type_code": "NA",
+    "cpr_otc_settlement_bank_account_code": "NA",
+    "cpr_deposit_quantity": 1,
+    "cpr_deposit_unit_price_value": "NA",
+    "cpr_payment_method_code": "NA",
+    "cpr_index_code": "NA",
+    "cpr_index_short_name": "NA",
+    "cpr_vcp_indicator_type_code": "NA",
+    "cpr_indexador_percentage_value": "NA",
+    "cpr_interest_rate_spread_percentage": "NA",
+    "cpr_interest_rate_criteria_type_code": "NA",
+    "cpr_interest_payment_date": "NA",
+    "cpr_interest_payment_value": "NA",
+    "cpr_interest_payment_frequency_code": "NA",
+    "cpr_interest_months_quantity": "NA",
+    "cpr_interestPaymentFlow_time_unit_type_code": "NA",
+    "cpr_interestPaymentFlow_deadline_type_code": "NA",
+    "cpr_payment_start_date": "NA",
+    "cpr_amortization_type_code": "NA",
+    "cpr_amortization_months_quantity": "NA",
+    "cpr_amortizationPaymentFlow_time_unit_type_code": "NA",
+    "cpr_amortizationPaymentFlow_deadline_type_code": "NA",
+    "cpr_amortization_start_date": "NA",
+    "cpr_scr_type_code": "N",
+    "cpr_scr_customer_detail": "NA",
+    "cpr_scr_person_type_acronym": "NA",
+    "cpr_deposit_document_number": "NA",
+    "cpr_scr_document_number": "NA",
+    "cpr_creditor_document_number": "47.175.222/0001-09",
+    "cpr_contract_code": "00700",
+    "cpr_operation_modality_type_code": "NA",
+    "cpr_bacen_reference_code": "NA",
+    "cpr_deliveryPlace_state_acronym": "MT",
+    "cpr_deliveryPlace_city_name": "PORTO DOS GAÚCHOS",
+    "cpr_deliveryPlace_ibge_code": "NA FAZENDA EM PRODUÇÃO",
+    "cpr_children_codes": ["NA"]
+  }
+}
+```
+
+### **cURL Example**
+
+```bash
+curl --location 'http://localhost:8000/b3/cpr/register' \
+  -H 'Content-Type: application/json' \
+  -H 'Authorization: Bearer <JWT>' \
+  --data '{
+    "cpr_type_code": "P",
+    "cpr_otc_register_account_code": "64359.40-5",
+    "cpr_otc_payment_agent_account_code": "64359.40-5",
+    "cpr_otc_custodian_account_code": "64359.00-3",
+    "cpr_internal_control_number": "NA",
+    "cpr_electronic_emission_indicator": "SIM",
+    "cpr_isin_code": "NA",
+    "cpr_issue_date": "2025-11-28",
+    "cpr_maturity_date": "2026-09-30",
+    "cpr_issue_quantity": 1,
+    "cpr_issue_value": 1710000.00,
+    "cpr_issue_financial_value": 1710000.00,
+    "cpr_unit_value": 1710000.00,
+    "cpr_reference_date": "2025-12-09",
+    "cpr_profitability_start_date": "2025-12-09",
+    "cpr_automatic_expiration_indicator": "NÃO",
+    "cpr_collateral_type_code": "Penhor",
+    "cpr_collateral_type_name": "NA",
+    "cpr_constitution_process_indicator": "SIM",
+    "cpr_otc_bondsman_account_code": "NA",
+    "cpr_collaterals_document_number": "NA",
+    "cpr_product_name": "MILHO",
+    "cpr_product_class_name": "MILHO (EM GRAOS)",
+    "cpr_product_harvest": "2026/2026",
+    "cpr_product_description": "Milho brasileiro em grãos, tipo exportação.",
+    "cpr_product_quantity": 2700000,
+    "cpr_measure_unit_name": "QUILO",
+    "cpr_packaging_way_name": "SACA (60 KG)",
+    "cpr_product_status_code": "A PRODUZIR",
+    "cpr_production_type_code": "PROPRIA",
+    "cpr_issuer_name": "MARCELO VINCENZI",
+    "cpr_finality_code": "NA",
+    "cpr_ipoc_code": "NA",
+    "cpr_calculation_type_code": "NA",
+    "cpr_initial_exchange_value": "NA",
+    "cpr_fixing_type_code": "NA",
+    "cpr_data_source_type_code": "NA",
+    "cpr_adjustment_frequency_type_code": "NA",
+    "cpr_adjustment_pro_rata_type_code": "NA",
+    "cpr_adjustment_type_code": "NA",
+    "cpr_creditor_name": "TOO EASY TRADING LTDA",
+    "cpr_ballast_type_code": "NA",
+    "cpr_lot_number": "NA",
+    "cpr_ballast_quantity": "NA",
+    "cpr_currency_code": "NA",
+    "cpr_transaction_identification": "NA",
+    "cpr_additional_text": "NA",
+    "cpr_number": "NA",
+    "cpr_contract_number": "NA",
+    "cpr_event_type_code": "NA",
+    "cpr_event_original_date": "NA",
+    "cpr_unit_price_value": 1710000.00,
+    "cpr_interest_unit_price_value": 0.00,
+    "cpr_residual_value": "NA",
+    "cpr_amortization_percentage": "NA",
+    "cpr_event_quantity": "NA",
+    "cpr_production_place_name": "FAZENDA CORAÇÃO DE MARIA",
+    "cpr_property_registration_number": "14406",
+    "cpr_notary_name": "NA",
+    "cpr_total_production_area_in_hectares_number": "NA",
+    "cpr_total_area_in_hectares_number": 670,
+    "cpr_car_code": "NA",
+    "cpr_latitude_code": "NA",
+    "cpr_longitude_code": "NA",
+    "cpr_zip_code": "78560000",
+    "cpr_green_cpr_indicator": "NA",
+    "cpr_green_cpr_certificate_name": "NA",
+    "cpr_green_cpr_certificate_cnpj_number": "NA",
+    "cpr_green_cpr_georeferencing_description": "NA",
+    "cpr_green_cpr_declaration_indicator": "NA",
+    "cpr_document_deadline_days_number": "NA",
+    "cpr_place_name": "NA",
+    "cpr_guarantee_limit_type_code": "NA",
+    "cpr_mother_code": "NA",
+    "cpr_issuers_person_type_acronym": "PF",
+    "cpr_issuers_state_acronym": "MT",
+    "cpr_issuers_city_name": "SINOP",
+    "cpr_issuers_ibge_code": "NA",
+    "cpr_issuer_legal_nature_code": "02",
+    "cpr_otc_favored_account_code": "64359.00-3",
+    "cpr_issuers_document_number": "867.308.271-49",
+    "cpr_deposit_person_type_acronym": "PF",
+    "cpr_self_number": "NA",
+    "cpr_settlement_modality_type_code": "NA",
+    "cpr_otc_settlement_bank_account_code": "NA",
+    "cpr_deposit_quantity": 1,
+    "cpr_deposit_unit_price_value": "NA",
+    "cpr_payment_method_code": "NA",
+    "cpr_index_code": "NA",
+    "cpr_index_short_name": "NA",
+    "cpr_vcp_indicator_type_code": "NA",
+    "cpr_indexador_percentage_value": "NA",
+    "cpr_interest_rate_spread_percentage": "NA",
+    "cpr_interest_rate_criteria_type_code": "NA",
+    "cpr_interest_payment_date": "NA",
+    "cpr_interest_payment_value": "NA",
+    "cpr_interest_payment_frequency_code": "NA",
+    "cpr_interest_months_quantity": "NA",
+    "cpr_interestPaymentFlow_time_unit_type_code": "NA",
+    "cpr_interestPaymentFlow_deadline_type_code": "NA",
+    "cpr_payment_start_date": "NA",
+    "cpr_amortization_type_code": "NA",
+    "cpr_amortization_months_quantity": "NA",
+    "cpr_amortizationPaymentFlow_time_unit_type_code": "NA",
+    "cpr_amortizationPaymentFlow_deadline_type_code": "NA",
+    "cpr_amortization_start_date": "NA",
+    "cpr_scr_type_code": "N",
+    "cpr_scr_customer_detail": "NA",
+    "cpr_scr_person_type_acronym": "NA",
+    "cpr_deposit_document_number": "NA",
+    "cpr_scr_document_number": "NA",
+    "cpr_creditor_document_number": "47.175.222/0001-09",
+    "cpr_contract_code": "00700",
+    "cpr_operation_modality_type_code": "NA",
+    "cpr_bacen_reference_code": "NA",
+    "cpr_deliveryPlace_state_acronym": "MT",
+    "cpr_deliveryPlace_city_name": "PORTO DOS GAÚCHOS",
+    "cpr_deliveryPlace_ibge_code": "NA FAZENDA EM PRODUÇÃO",
+    "cpr_children_codes": ["NA"]
+  }'
+```
+
+### **Responses**
+
+#### **200 OK (Success)**
+
+Returns the JSON body from B3.
+
+#### **400 Bad Request**
+
+Returned if the JSON is invalid or CPR payload is missing.
+
+#### **401 Unauthorized**
+
+Returned if the JWT is missing/invalid.
+
+#### **502 Bad Gateway**
+
+Returned if the request to B3 fails.

+ 265 - 0
services/B3CprService.php

@@ -0,0 +1,265 @@
+<?php
+
+namespace Services;
+
+class B3CprService
+{
+    public function getAccessToken(): string
+    {
+        $b3Url = $_ENV['B3_URL'] ?? null;
+        $grantType = $_ENV['GRANT_TYPE'] ?? 'client_credentials';
+        $certPass = $_ENV['CERT_PASS'] ?? null;
+
+        if (!$b3Url || !$grantType) {
+            throw new \RuntimeException('Missing required env vars: B3_URL, GRANT_TYPE');
+        }
+
+        $certPath = dirname(__DIR__) . DIRECTORY_SEPARATOR . '319245319-320109623_PROD.cer';
+        $keyPath  = dirname(__DIR__) . DIRECTORY_SEPARATOR . '319245319-320109623_PROD.key';
+        if (!file_exists($certPath) || !file_exists($keyPath)) {
+            throw new \RuntimeException('Client certificate or key not found');
+        }
+
+        $postFields = 'grant_type=' . ($grantType);
+
+        $ch = curl_init($b3Url);
+        curl_setopt($ch, CURLOPT_POST, true);
+        $headers = [
+            'Content-Type: application/x-www-form-urlencoded',
+            'Accept: application/json',
+        ];
+        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+        curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+
+        curl_setopt($ch, CURLOPT_SSLCERT, $certPath);
+        curl_setopt($ch, CURLOPT_SSLCERTTYPE, 'PEM');
+        curl_setopt($ch, CURLOPT_SSLKEY, $keyPath);
+        curl_setopt($ch, CURLOPT_SSLKEYTYPE, 'PEM');
+        if ($certPass) {
+            curl_setopt($ch, CURLOPT_SSLKEYPASSWD, $certPass);
+        }
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+
+        $responseBody = curl_exec($ch);
+        $curlErrNo = curl_errno($ch);
+        $curlErr = curl_error($ch);
+        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        curl_close($ch);
+
+        if ($curlErrNo !== 0) {
+            throw new \RuntimeException('cURL error: ' . $curlErr);
+        }
+
+        $decoded = json_decode((string)$responseBody, true);
+        if (!is_array($decoded)) {
+            throw new \RuntimeException('Invalid token response (non-JSON), HTTP ' . $httpCode);
+        }
+        $token = $decoded['access_token'] ?? null;
+        if (!is_string($token) || $token === '') {
+            throw new \RuntimeException('access_token not present in token response');
+        }
+        return $token;
+    }
+    public function mapToB3(array $cpr): array
+    {
+        $instrument = [
+            'cprTypeCode' => $cpr['cpr_type_code'] ?? null,
+            'otcRegisterAccountCode' => $cpr['cpr_otc_register_account_code'] ?? null,
+            'otcCustodianAccountCode' => $cpr['cpr_otc_custodian_account_code'] ?? null,
+            'electronicEmissionIndicator' => $this->mapIndicator($cpr['cpr_electronic_emission_indicator'] ?? null),
+            'issueDate' => $this->convertDate($cpr['cpr_issue_date'] ?? null),
+            'maturityDate' => $this->convertDate($cpr['cpr_maturity_date'] ?? null),
+            'issueQuantity' => $cpr['cpr_issue_quantity'] ?? null,
+            'issueValue' => $cpr['cpr_issue_value'] ?? null,
+            'issueFinancialValue' => $cpr['cpr_issue_financial_value'] ?? null,
+            'profitabilityStartDate' => $this->convertDate($cpr['cpr_profitability_start_date'] ?? null),
+            'automaticExpirationIndicator' => $this->mapIndicator($cpr['cpr_automatic_expiration_indicator'] ?? null),
+            'collaterals' => [[
+                'collateralTypeCode' => $cpr['cpr_collateral_type_code'] ?? null,
+                'collateralTypeName' => $cpr['cpr_collateral_type_name'] ?? null,
+                'constitutionProcessIndicator' => $this->mapIndicator($cpr['cpr_constitution_process_indicator'] ?? null),
+                'otcBondsmanAccountCode' => $cpr['cpr_otc_bondsman_account_code'] ?? null,
+            ]],
+            'products' => [[
+                'cprProductName' => $cpr['cpr_product_name'] ?? null,
+                'cprProductClassName' => $cpr['cpr_product_class_name'] ?? null,
+                'cprProductHarvest' => $cpr['cpr_product_harvest'] ?? null,
+                'cprProductDescription' => $cpr['cpr_product_description'] ?? null,
+                'cprProductQuantity' => $cpr['cpr_product_quantity'] ?? null,
+                'measureUnitName' => $cpr['cpr_measure_unit_name'] ?? null,
+                'packagingWayName' => $cpr['cpr_packaging_way_name'] ?? null,
+                'cprProductStatusCode' => $cpr['cpr_product_status_code'] ?? null,
+                'productionTypeCode' => $cpr['cpr_production_type_code'] ?? null,
+            ]],
+            'deliveryPlace' => [
+                'documentDeadlineDaysNumber' => $cpr['cpr_document_deadline_days_number'] ?? null,
+                'placeName' => $cpr['cpr_place_name'] ?? null,
+                'stateAcronym' => $cpr['cpr_deliveryPlace_state_acronym'] ?? null,
+                'cityName' => $cpr['cpr_deliveryPlace_city_name'] ?? null,
+            ],
+            'issuers' => [[
+                'cprIssuerName' => $cpr['cpr_issuer_name'] ?? null,
+                'documentNumber' => $cpr['cpr_issuers_document_number'] ?? null,
+                'personTypeAcronym' => $cpr['cpr_issuers_person_type_acronym'] ?? null,
+                'issuerLegalNatureCode' => $cpr['cpr_issuer_legal_nature_code'] ?? null,
+                'stateAcronym' => $cpr['cpr_issuers_state_acronym'] ?? null,
+                'cityName' => $cpr['cpr_issuers_city_name'] ?? null,
+            ]],
+            'scr' => [
+                'scrTypeCode' => $cpr['cpr_scr_type_code'] ?? null,
+                'finalityCode' => $cpr['cpr_finality_code'] ?? null,
+                'contractCode' => $cpr['cpr_contract_code'] ?? null,
+            ],
+            'creditor' => [
+                'creditorName' => $cpr['cpr_creditor_name'] ?? null,
+                'documentNumber' => $cpr['cpr_creditor_document_number'] ?? null,
+            ],
+            'productionPlaces' => [[
+                'productionPlaceName' => $cpr['cpr_production_place_name'] ?? null,
+                'propertyRegistrationNumber' => $cpr['cpr_property_registration_number'] ?? null,
+                'notaryName' => $cpr['cpr_notary_name'] ?? null,
+                'totalProductionAreaInHectaresNumber' => $cpr['cpr_total_production_area_in_hectares_number'] ?? null,
+                'totalAreaInHectaresNumber' => $cpr['cpr_total_area_in_hectares_number'] ?? null,
+                'carCode' => $cpr['cpr_car_code'] ?? null,
+                'latitudeCode' => $cpr['cpr_latitude_code'] ?? null,
+                'longitudeCode' => $cpr['cpr_longitude_code'] ?? null,
+                'zipCode' => $cpr['cpr_zip_code'] ?? null,
+            ]],
+        ];
+
+        return ['data' => ['instrument' => $instrument]];
+    }
+
+    private function convertDate(?string $date): ?string
+    {
+        if (!is_string($date) || trim($date) === '') {
+            return null;
+        }
+        $t = trim($date);
+        if (mb_strtoupper($t, 'UTF-8') === 'NA') {
+            return null;
+        }
+        $dt = \DateTime::createFromFormat('Y-m-d', $t);
+        if ($dt instanceof \DateTime) {
+            return $dt->format('d/m/Y');
+        }
+        $alt = \DateTime::createFromFormat('d/m/Y', $t);
+        if ($alt instanceof \DateTime) {
+            return $t;
+        }
+        return $t;
+    }
+
+    private function mapIndicator($value): ?string
+    {
+        if (!is_string($value)) {
+            return null;
+        }
+        $v = mb_strtoupper(trim($value), 'UTF-8');
+        if ($v === 'SIM' || $v === 'S') {
+            return 'S';
+        }
+        if ($v === 'N' || $v === 'NAO' || $v === 'NÃO') {
+            return 'N';
+        }
+        if ($v === 'NA') {
+            return null;
+        }
+        return $value;
+    }
+
+    public function sanitize(array $payload): array
+    {
+        return $this->removeNulls($payload);
+    }
+
+    private function removeNulls($value)
+    {
+        if (is_array($value)) {
+            $isAssoc = $this->isAssoc($value);
+            $result = [];
+            foreach ($value as $k => $v) {
+                if (is_string($v) && mb_strtoupper(trim($v), 'UTF-8') === 'NA') {
+                    $v = null;
+                }
+                $v = $this->removeNulls($v);
+                if (is_array($v) && count($v) === 0) {
+                    continue;
+                }
+                $result[$k] = $v;
+            }
+            if (!$isAssoc) {
+                $result = array_values($result);
+            }
+            return $result;
+        }
+        if (is_string($value) && mb_strtoupper(trim($value), 'UTF-8') === 'NA') {
+            return null;
+        }
+        return $value;
+    }
+
+    private function isAssoc(array $arr): bool
+    {
+        return array_keys($arr) !== range(0, count($arr) - 1);
+    }
+
+    public function postCpr(string $accessToken, array $payload): array
+    {
+        $url = $_ENV['B3_URL_CPR'] ?? null;
+        if (!$url) {
+            throw new \RuntimeException('B3_URL_CPR not configured');
+        }
+
+        $json = json_encode($this->sanitize($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        if ($json === false) {
+            throw new \RuntimeException('Failed to encode payload');
+        }
+
+        $certPath = dirname(__DIR__) . DIRECTORY_SEPARATOR . '319245319-320109623_PROD.cer';
+        $keyPath = dirname(__DIR__) . DIRECTORY_SEPARATOR . '319245319-320109623_PROD.key';
+        $certPass = $_ENV['CERT_PASS'] ?? null;
+        if (!file_exists($certPath) || !file_exists($keyPath)) {
+            throw new \RuntimeException('Client certificate or key not found');
+        }
+
+        $ch = curl_init($url);
+        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
+        $headers = [
+            'Accept: application/json',
+            'Content-Type: application/json',
+            'Authorization: Bearer ' . $accessToken,
+        ];
+        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+        curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
+        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+
+        curl_setopt($ch, CURLOPT_SSLCERT, $certPath);
+        curl_setopt($ch, CURLOPT_SSLCERTTYPE, 'PEM');
+        curl_setopt($ch, CURLOPT_SSLKEY, $keyPath);
+        curl_setopt($ch, CURLOPT_SSLKEYTYPE, 'PEM');
+        if ($certPass) {
+            curl_setopt($ch, CURLOPT_SSLKEYPASSWD, $certPass);
+        }
+        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
+        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
+
+        $responseBody = curl_exec($ch);
+        $curlErrNo = curl_errno($ch);
+        $curlErr = curl_error($ch);
+        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        curl_close($ch);
+
+        if ($curlErrNo !== 0) {
+            return ['error' => $curlErr, 'status' => 0];
+        }
+
+        $decoded = json_decode((string)$responseBody, true);
+        if ($decoded === null) {
+            return ['raw' => $responseBody, 'status' => $httpCode ?: 502];
+        }
+        return ['json' => $decoded, 'status' => $httpCode ?: 200];
+    }
+}