소스 검색

add the payment system

gdias 3 주 전
부모
커밋
0ce4540a34
2개의 변경된 파일451개의 추가작업 그리고 17개의 파일을 삭제
  1. 13 13
      src/lib/components/commodities/cpr/RegisterCpr.svelte
  2. 438 4
      src/routes/cpr/+page.svelte

+ 13 - 13
src/lib/components/commodities/cpr/RegisterCpr.svelte

@@ -23,19 +23,19 @@
     //     // { key: 'cpr_automatic_expiration_indicator', label: 'Expiração Automática', type: 'select', options: indicatorOptions }
     //   ]
     // },
-    {
-      title: 'Contas OTC e Liquidação',
-      description: 'Vinculação das contas na câmara de registro e liquidação.',
-      columns: 2,
-      fields: [
-        // { key: 'cpr_otc_register_account_code', label: 'Conta OTC de Registro' },
-        // { key: 'cpr_otc_payment_agent_account_code', label: 'Conta OTC Agente de Pagamento' },
-        // { key: 'cpr_otc_custodian_account_code', label: 'Conta OTC Custódia' },
-        { key: 'cpr_otc_favored_account_code', label: 'Conta OTC Favorecido' },
-        { key: 'cpr_settlement_modality_type_code', label: 'Modalidade de Liquidação' },
-        { key: 'cpr_otc_settlement_bank_account_code', label: 'Conta Bancária de Liquidação' }
-      ]
-    },
+    // {
+    //   title: 'Contas OTC e Liquidação',
+    //   description: 'Vinculação das contas na câmara de registro e liquidação.',
+    //   columns: 2,
+    //   fields: [
+    //     // { key: 'cpr_otc_register_account_code', label: 'Conta OTC de Registro' },
+    //     // { key: 'cpr_otc_payment_agent_account_code', label: 'Conta OTC Agente de Pagamento' },
+    //     // { key: 'cpr_otc_custodian_account_code', label: 'Conta OTC Custódia' },
+    //     // { key: 'cpr_otc_favored_account_code', label: 'Conta OTC Favorecido' },
+    //     // { key: 'cpr_settlement_modality_type_code', label: 'Modalidade de Liquidação' },
+    //     // { key: 'cpr_otc_settlement_bank_account_code', label: 'Conta Bancária de Liquidação' }
+    //   ]
+    // },
     // {
     //   title: 'Depósito e Garantias',
     //   description: 'Detalhes complementares sobre garantias e depósitos.',

+ 438 - 4
src/routes/cpr/+page.svelte

@@ -1,5 +1,6 @@
 <script>
-  import { onMount } from 'svelte';
+  import { browser } from '$app/environment';
+  import { onDestroy, onMount, tick } from 'svelte';
   import { get } from 'svelte/store';
   import Header from '$lib/layout/Header.svelte';
   import Tabs from '$lib/components/Tabs.svelte';
@@ -147,7 +148,6 @@
     'cpr_place_name',
     'cpr_deliveryPlace_state_acronym',
     'cpr_deliveryPlace_city_name',
-    'cpr_deliveryPlace_ibge_code',
     'cpr_issuer_legal_nature_code',
     'cpr_issuers_person_type_acronym',
     'cpr_issuers_document_number',
@@ -274,11 +274,30 @@
   let submitSuccess = '';
   let isSubmitting = false;
 
+  const PAYMENT_STORAGE_KEY = 'tooeasy_cpr_payment';
+  const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000;
+  const PAYMENT_POLL_INTERVAL_MS = 10_000;
+  let paymentModalVisible = false;
+  let paymentCode = '';
+  let paymentId = null;
+  let paymentError = '';
+  let paymentStatusMessage = '';
+  let paymentCopyFeedback = '';
+  let paymentCountdownMs = PAYMENT_TIMEOUT_MS;
+  let paymentStartedAt = null;
+  let paymentQrContainer;
+  let paymentQrInstance = null;
+  let paymentPollIntervalId = null;
+  let paymentCountdownIntervalId = null;
+  let paymentCopyTimeoutId = null;
+  let isCheckingPayment = false;
+
   const breadcrumb = [{ label: 'Início' }, { label: 'CPR', active: true }];
   let activeTab = 4;
   const tabs = ['Contrato', 'Registro', 'Emissão'];
 
   const historyEndpoint = `${apiUrl}/cpr/history`;
+  const paymentConfirmEndpoint = `${apiUrl}/b3/payment/confirm`;
   const historyColumns = [
     { key: 'cpr_id', label: 'CPR ID' },
     { key: 'cpr_product_class_name', label: 'Produto' },
@@ -429,6 +448,312 @@
     return numeric.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
   }
 
+  function formatCountdown(ms) {
+    if (ms == null || Number.isNaN(ms)) return '--:--';
+    const totalSeconds = Math.max(0, Math.floor(ms / 1000));
+    const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0');
+    const seconds = String(totalSeconds % 60).padStart(2, '0');
+    return `${minutes}:${seconds}`;
+  }
+
+  function persistPaymentState() {
+    if (!browser || !paymentId || !paymentCode) return;
+    try {
+      const payload = {
+        paymentId,
+        paymentCode,
+        startedAt: paymentStartedAt ?? Date.now()
+      };
+      localStorage.setItem(PAYMENT_STORAGE_KEY, JSON.stringify(payload));
+    } catch (err) {
+      console.warn('[CPR] Não foi possível salvar o estado do pagamento', err);
+    }
+  }
+
+  function removePaymentStateFromStorage() {
+    if (!browser) return;
+    try {
+      localStorage.removeItem(PAYMENT_STORAGE_KEY);
+    } catch {}
+  }
+
+  function stopPaymentPolling() {
+    if (paymentPollIntervalId) {
+      clearInterval(paymentPollIntervalId);
+      paymentPollIntervalId = null;
+    }
+  }
+
+  function stopPaymentCountdown() {
+    if (paymentCountdownIntervalId) {
+      clearInterval(paymentCountdownIntervalId);
+      paymentCountdownIntervalId = null;
+    }
+  }
+
+  function stopPaymentCopyTimeout() {
+    if (paymentCopyTimeoutId) {
+      clearTimeout(paymentCopyTimeoutId);
+      paymentCopyTimeoutId = null;
+    }
+  }
+
+  function startPaymentCountdown(startTimestamp = Date.now()) {
+    if (!browser) return;
+    paymentStartedAt = startTimestamp;
+    updatePaymentCountdown();
+    stopPaymentCountdown();
+    paymentCountdownIntervalId = window.setInterval(updatePaymentCountdown, 1000);
+  }
+
+  function updatePaymentCountdown() {
+    if (!paymentStartedAt) {
+      paymentCountdownMs = PAYMENT_TIMEOUT_MS;
+      return;
+    }
+    const elapsed = Date.now() - paymentStartedAt;
+    const remaining = PAYMENT_TIMEOUT_MS - elapsed;
+    paymentCountdownMs = remaining > 0 ? remaining : 0;
+    if (remaining <= 0) {
+      handlePaymentExpired();
+    }
+  }
+
+  function handlePaymentExpired() {
+    if (!paymentId) return;
+    stopPaymentPolling();
+    stopPaymentCountdown();
+    removePaymentStateFromStorage();
+    paymentStatusMessage = '';
+    paymentError = 'O tempo limite para pagamento expirou. Gere uma nova CPR para emitir outro QR Code.';
+    paymentId = null;
+    paymentCode = '';
+  }
+
+  function startPaymentPolling(immediate = true) {
+    if (!browser || !paymentId) return;
+    stopPaymentPolling();
+    if (immediate) {
+      void pollPaymentStatus();
+    }
+    paymentPollIntervalId = window.setInterval(() => {
+      void pollPaymentStatus();
+    }, PAYMENT_POLL_INTERVAL_MS);
+  }
+
+  async function pollPaymentStatus() {
+    if (!paymentId || isCheckingPayment) return;
+    isCheckingPayment = true;
+    paymentError = '';
+    try {
+      const result = await requestPaymentConfirmation();
+      if (result.state === 'success') {
+        handlePaymentConfirmationSuccess(result.data);
+      } else if (result.state === 'pending') {
+        paymentStatusMessage = result.message ?? 'Pagamento pendente. Continuaremos monitorando.';
+      } else if (result.state === 'error') {
+        paymentStatusMessage = '';
+        paymentError = result.message ?? 'Falha ao confirmar pagamento.';
+        stopPaymentPolling();
+      }
+    } catch (err) {
+      console.error('[CPR] Erro ao confirmar pagamento', err);
+      paymentError = err?.message ?? 'Não foi possível verificar o pagamento.';
+    } finally {
+      isCheckingPayment = false;
+    }
+  }
+
+  async function requestPaymentConfirmation() {
+    if (!paymentId) {
+      return { state: 'error', message: 'Pagamento inválido. Gere um novo QR Code.' };
+    }
+    const { token, company_id } = ensureAuthContext();
+    const res = await fetch(paymentConfirmEndpoint, {
+      method: 'POST',
+      headers: {
+        'content-type': 'application/json',
+        Authorization: `Bearer ${token}`
+      },
+      body: JSON.stringify({ payment_id: paymentId, company_id })
+    });
+    const raw = await res.text();
+    let body = null;
+    if (raw) {
+      try {
+        body = JSON.parse(raw);
+      } catch (err) {
+        console.error('[CPR] Resposta inválida ao confirmar pagamento:', err, raw);
+      }
+    }
+    if (res.ok && body?.success) {
+      return { state: 'success', data: body };
+    }
+    const code = body?.code ?? body?.error;
+    const fallbackMessage = body?.data?.message ?? body?.message ?? body?.msg ?? raw ?? 'Falha ao confirmar pagamento.';
+    if (code === 'E_PAYMENT_PENDING' || body?.data?.status === 0) {
+      return { state: 'pending', message: 'Pagamento pendente. Assim que confirmado, você será avisado.' };
+    }
+    const message = mapPaymentError(code, res.status, fallbackMessage);
+    return { state: 'error', message };
+  }
+
+  function mapPaymentError(code, status, fallback) {
+    const normalized = code?.toUpperCase?.();
+    switch (normalized) {
+      case 'E_VALIDATE':
+        return 'Payment_id inválido. Registre uma nova CPR para gerar outro QR Code.';
+      case 'E_NOT_FOUND':
+        return 'Pagamento não encontrado. Gere um novo QR Code.';
+      case 'E_PAYMENT_STATUS':
+        return 'Pagamento em status inválido. Gere um novo QR Code.';
+      case 'E_CPR_NOT_FOUND':
+        return 'CPR não vinculada ao pagamento. Entre em contato com o suporte.';
+      case 'E_EXTERNAL':
+        return 'Erro externo ao confirmar pagamento. Tente novamente em instantes.';
+      default:
+        if (status === 502) {
+          return 'Erro externo ao confirmar pagamento. Tente novamente em instantes.';
+        }
+        return fallback ?? 'Não foi possível confirmar o pagamento.';
+    }
+  }
+
+  function handlePaymentConfirmationSuccess(payload) {
+    const message = payload?.data?.message ?? payload?.message ?? 'Pagamento confirmado com sucesso.';
+    submitSuccess = message;
+    paymentError = '';
+    paymentStatusMessage = '';
+    resetPaymentTracking({ hideModal: true });
+    void fetchHistory();
+  }
+
+  function resetPaymentTracking({ hideModal = true } = {}) {
+    stopPaymentPolling();
+    stopPaymentCountdown();
+    stopPaymentCopyTimeout();
+    removePaymentStateFromStorage();
+    paymentId = null;
+    paymentCode = '';
+    paymentStartedAt = null;
+    paymentCountdownMs = PAYMENT_TIMEOUT_MS;
+    paymentCopyFeedback = '';
+    if (hideModal) {
+      paymentModalVisible = false;
+    }
+  }
+
+  function handleCancelPaymentFlow() {
+    resetPaymentTracking({ hideModal: true });
+    paymentStatusMessage = '';
+    paymentError = '';
+  }
+
+  function handleRetryPaymentCheck() {
+    if (!paymentId) return;
+    paymentError = '';
+    paymentStatusMessage = 'Verificando status do pagamento...';
+    startPaymentPolling(true);
+  }
+
+  async function renderPaymentQrCode(link) {
+    if (!browser || !link) return;
+    try {
+      if (!paymentQrInstance) {
+        const module = await import('qr-code-styling');
+        const QRCodeStyles = module.default ?? module;
+        paymentQrInstance = new QRCodeStyles({
+          width: 240,
+          height: 240,
+          type: 'svg',
+          data: link,
+          dotsOptions: {
+            color: '#0f172a',
+            type: 'rounded'
+          },
+          backgroundOptions: {
+            color: '#ffffff'
+          }
+        });
+      } else {
+        paymentQrInstance.update({ data: link });
+      }
+      await tick();
+      if (paymentQrContainer) {
+        paymentQrContainer.innerHTML = '';
+        paymentQrInstance.append(paymentQrContainer);
+      }
+    } catch (error) {
+      console.error('[CPR] Falha ao renderizar QR Code de pagamento', error);
+      paymentError = 'Não foi possível gerar o QR Code. Use o código Pix copiado para pagar.';
+    }
+  }
+
+  async function openPaymentModalWithPayment(data, startedAt = Date.now(), immediatePoll = true) {
+    if (!data?.payment_id || !data?.payment_code) return;
+    paymentModalVisible = true;
+    paymentId = Number(data.payment_id);
+    paymentCode = data.payment_code;
+    paymentError = '';
+    paymentStatusMessage = 'Aguardando pagamento via Pix.';
+    paymentCopyFeedback = '';
+    paymentStartedAt = startedAt;
+    const remaining = PAYMENT_TIMEOUT_MS - (Date.now() - startedAt);
+    paymentCountdownMs = remaining > 0 ? remaining : PAYMENT_TIMEOUT_MS;
+    persistPaymentState();
+    await tick();
+    await renderPaymentQrCode(paymentCode);
+    startPaymentCountdown(startedAt);
+    startPaymentPolling(immediatePoll);
+  }
+
+  async function restorePersistedPayment() {
+    if (!browser) return;
+    try {
+      const raw = localStorage.getItem(PAYMENT_STORAGE_KEY);
+      if (!raw) return;
+      const stored = JSON.parse(raw);
+      if (!stored?.paymentId || !stored?.paymentCode) {
+        removePaymentStateFromStorage();
+        return;
+      }
+      const startedAt = stored.startedAt ?? Date.now();
+      const remaining = PAYMENT_TIMEOUT_MS - (Date.now() - startedAt);
+      if (remaining <= 0) {
+        removePaymentStateFromStorage();
+        return;
+      }
+      await openPaymentModalWithPayment(
+        { payment_id: stored.paymentId, payment_code: stored.paymentCode },
+        startedAt,
+        false
+      );
+      paymentStatusMessage = 'Retomamos a verificação do pagamento pendente.';
+      startPaymentPolling(true);
+    } catch (err) {
+      console.error('[CPR] Não foi possível restaurar o pagamento pendente', err);
+      removePaymentStateFromStorage();
+    }
+  }
+
+  async function handleCopyPaymentCode() {
+    if (!browser || !paymentCode) return;
+    try {
+      await navigator.clipboard.writeText(paymentCode);
+      paymentCopyFeedback = 'Código Pix copiado!';
+    } catch {
+      paymentCopyFeedback = 'Não foi possível copiar automaticamente. Copie manualmente.';
+    } finally {
+      stopPaymentCopyTimeout();
+      if (browser) {
+        paymentCopyTimeoutId = window.setTimeout(() => {
+          paymentCopyFeedback = '';
+          paymentCopyTimeoutId = null;
+        }, 2000);
+      }
+    }
+  }
+
   async function fetchHistory() {
     historyLoading = true;
     historyError = '';
@@ -515,6 +840,13 @@
 
   onMount(() => {
     void fetchHistory();
+    void restorePersistedPayment();
+  });
+
+  onDestroy(() => {
+    stopPaymentPolling();
+    stopPaymentCountdown();
+    stopPaymentCopyTimeout();
   });
 
   function getMissingRequiredFields() {
@@ -616,14 +948,24 @@
 
       const serverStatus = response?.status?.toLowerCase?.() ?? '';
       if (!res.ok || (response?.status && serverStatus !== 'ok')) {
-        throw new Error(response?.msg ?? 'Falha ao registrar CPR.');
+        console.error('[CPR] Falha ao registrar CPR', {
+          status: res.status,
+          statusText: res.statusText,
+          raw,
+          response
+        });
+        throw new Error((response?.msg ?? raw) || 'Falha ao registrar CPR.');
       }
 
       submitSuccess = response?.msg ?? 'CPR registrada com sucesso.';
+      const paymentData = response?.data;
       cprForm = createInitialForm();
       repeatingGroups = createInitialRepeatingGroups();
-      activeTab = 4;
+      if (paymentData?.payment_id && paymentData?.payment_code) {
+        await openPaymentModalWithPayment(paymentData);
+      }
     } catch (err) {
+      console.error('[CPR] Erro inesperado ao registrar CPR', err);
       submitError = err?.message ?? 'Falha ao registrar CPR.';
     } finally {
       isSubmitting = false;
@@ -827,4 +1169,96 @@
       {/if}
     </div>
   </div>
+
+  {#if paymentModalVisible}
+    <div class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 px-4">
+      <div class="w-full max-w-xl rounded-2xl bg-white dark:bg-gray-900 p-6 shadow-2xl relative">
+        <button
+          class="absolute top-4 right-4 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
+          aria-label="Fechar"
+          on:click={handleCancelPaymentFlow}
+        >
+          ✕
+        </button>
+        <div class="space-y-4">
+          <div>
+            <p class="text-sm uppercase tracking-wider text-blue-500 font-semibold">Pagamento pendente</p>
+            <h3 class="text-2xl font-bold text-gray-900 dark:text-white">Finalize a CPR via Pix</h3>
+            <p class="text-sm text-gray-600 dark:text-gray-300">
+              Utilize o QR Code ou copie o código Pix para concluir o pagamento. O QR Code expira em
+              <span class="font-semibold">{formatCountdown(paymentCountdownMs)}</span>.
+            </p>
+          </div>
+
+          <div class="flex flex-col gap-6 md:flex-row">
+            <div class="flex-1 rounded-xl border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80 p-4 flex flex-col items-center gap-4">
+              <div class="text-center">
+                <p class="text-xs uppercase tracking-wider text-gray-500">QR Code Pix</p>
+                <p class="text-sm text-gray-600 dark:text-gray-300">Escaneie com o app do banco</p>
+              </div>
+              <div
+                class="w-56 h-56 flex items-center justify-center bg-white rounded-xl shadow-inner"
+                bind:this={paymentQrContainer}
+                aria-label="QR Code do pagamento"
+              ></div>
+              <button
+                type="button"
+                class="w-full rounded-lg bg-blue-600 hover:bg-blue-700 text-white py-2 text-sm font-semibold transition disabled:opacity-60"
+                on:click={handleCopyPaymentCode}
+                disabled={!paymentCode}
+              >
+                {paymentCopyFeedback ? paymentCopyFeedback : 'Copiar código Pix'}
+              </button>
+            </div>
+
+            <div class="flex-1 space-y-4">
+              <div class="rounded-lg border border-amber-200 bg-amber-50 text-amber-900 px-3 py-2 text-sm">
+                <p class="font-semibold text-amber-900">Tempo restante</p>
+                <p class="text-3xl font-mono tracking-widest">{formatCountdown(paymentCountdownMs)}</p>
+              </div>
+
+              <div class="space-y-2">
+                <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Código Pix copia e cola</label>
+                <textarea
+                  class="w-full text-sm rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-100 p-2 resize-none"
+                  rows="4"
+                  readonly
+                  value={paymentCode}
+                ></textarea>
+              </div>
+
+              {#if paymentStatusMessage}
+                <div class="rounded-lg border border-blue-200 bg-blue-50 text-blue-900 px-3 py-2 text-sm">
+                  {paymentStatusMessage}
+                </div>
+              {/if}
+              {#if paymentError}
+                <div class="rounded-lg border border-red-200 bg-red-50 text-red-900 px-3 py-2 text-sm">
+                  {paymentError}
+                </div>
+              {/if}
+
+              <div class="flex flex-wrap gap-2">
+                <button
+                  type="button"
+                  class="flex-1 min-w-[140px] inline-flex items-center justify-center rounded-lg border border-gray-300 dark:border-gray-700 px-3 py-2 text-sm font-semibold text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-60"
+                  on:click={handleRetryPaymentCheck}
+                  disabled={isCheckingPayment}
+                >
+                  {isCheckingPayment ? 'Verificando...' : 'Reverificar agora'}
+                </button>
+                <button
+                  type="button"
+                  class="flex-1 min-w-[140px] inline-flex items-center justify-center rounded-lg border border-red-300 px-3 py-2 text-sm font-semibold text-red-600 hover:bg-red-50"
+                  on:click={handleCancelPaymentFlow}
+                >
+                  Cancelar pagamento
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  {/if}
 </div>