ソースを参照

feat: add diferenciacao de taxa de pix e de credito

Gustavo Mantovani 1 週間 前
コミット
fed4ddf757

+ 10 - 0
src/api/payment.js

@@ -4,3 +4,13 @@ export const paySchedule = async (scheduleId, payload) => {
   const { data } = await api.post(`/payment/schedule/${scheduleId}/pay`, payload);
   return data.payload;
 };
+
+export const getSchedulePixPayment = async (scheduleId) => {
+  const { data } = await api.get(`/payment/schedule/${scheduleId}/pix`);
+  return data.payload;
+};
+
+export const getPaymentPlatformFees = async () => {
+  const { data } = await api.get('/payment/platform-fees');
+  return data.payload;
+};

+ 4 - 3
src/components/dashboard/NextSchedulesDetailsDialog.vue

@@ -126,6 +126,7 @@ import { useDialogPluginComponent, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import { formatCurrency } from 'src/helpers/utils'
 import { getScheduleClienteDetails } from 'src/api/dashboard'
+import { usePaymentPlatformFees } from 'src/composables/usePaymentPlatformFees'
 import ScheduleCancelDialog from './ScheduleCancelDialog.vue'
 import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
 import { formatAddress } from 'src/helpers/utils';
@@ -145,10 +146,12 @@ const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent()
 
 const details = ref(null)
 const loadingDetails = ref(true)
+const { platformFees, loadPlatformFees } = usePaymentPlatformFees()
 
 onMounted(async () => {
   try {
     details.value = await getScheduleClienteDetails(props.schedule.id)
+    loadPlatformFees().catch(() => {})
   } catch {
     $q.notify({ message: t('http.errors.failed'), color: 'negative' })
   } finally {
@@ -156,11 +159,9 @@ onMounted(async () => {
   }
 })
 
-const SERVICE_FEE_RATE = 0.11
-
 const serviceFee = computed(() => {
   const base = parseFloat(props.schedule.total_amount) || 0
-  return parseFloat((base * SERVICE_FEE_RATE).toFixed(2))
+  return parseFloat((base * platformFees.value.pix).toFixed(2))
 })
 
 const total = computed(() => {

+ 32 - 13
src/components/dashboard/ScheduleAcceptedDialog.vue

@@ -32,18 +32,22 @@
         </div>
         <div class="detail-row">
           <span class="text-primary font14 fontmedium">{{ $t('dashboard_client.pending_schedules.detail_value') }}</span>
-          <span class="detail-value">{{ formatCurrency(schedule.total_amount) }}</span>
-        </div>
-        <div class="detail-row">
-          <span class="text-primary font14 fontmedium">{{ $t('dashboard_client.pending_schedules.detail_service_fee') }}</span>
-          <span class="detail-value">{{ formatCurrency(serviceFee) }}</span>
+          <span class="detail-value">{{ formatCurrency(baseAmount) }}</span>
         </div>
 
         <q-separator class="q-my-sm" />
 
-        <div class="text-center">
-          <span class="text-primary font14 fontmedium">{{ $t('dashboard_client.pending_schedules.detail_total') }}</span>
-          <span class="total-value font14 fontbold q-ml-sm">{{ formatCurrency(total) }}</span>
+        <div class="detail-row">
+          <span class="text-primary font14 fontmedium">{{ $t('dashboard_client.pending_schedules.detail_pix_total') }}</span>
+          <span class="total-value font14 fontbold">{{ formatCurrency(pixTotal) }}</span>
+        </div>
+        <div v-if="pixDiscount > 0" class="detail-row discount-row">
+          <span class="font13 fontmedium">{{ $t('dashboard_client.pending_schedules.detail_pix_discount') }}</span>
+          <span class="font13 fontbold">{{ formatCurrency(pixDiscount) }}</span>
+        </div>
+        <div class="detail-row">
+          <span class="text-primary font14 fontmedium">{{ $t('dashboard_client.pending_schedules.detail_credit_card_total') }}</span>
+          <span class="total-value font14 fontbold">{{ formatCurrency(creditCardTotal) }}</span>
         </div>
       </q-card-section>
 
@@ -72,10 +76,11 @@
 </template>
 
 <script setup>
-import { computed } from 'vue'
+import { computed, onMounted } from 'vue'
 import { useDialogPluginComponent, useQuasar } from 'quasar'
 import { formatCurrency } from 'src/helpers/utils'
 import { usePaymentStore } from 'src/stores/payment'
+import { usePaymentPlatformFees } from 'src/composables/usePaymentPlatformFees'
 import SchedulePaymentDialog from './SchedulePaymentDialog.vue'
 import SchedulePaymentPixDialog from './SchedulePaymentPixDialog.vue'
 
@@ -92,11 +97,17 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent()
 
 const $q = useQuasar()
 const paymentStore = usePaymentStore()
+const { platformFees, loadPlatformFees } = usePaymentPlatformFees()
 
-const SERVICE_FEE_RATE = 0.11
+onMounted(() => {
+  loadPlatformFees().catch(() => {})
+})
 
-const serviceFee = computed(() => Number(props.schedule.total_amount) * SERVICE_FEE_RATE)
-const total = computed(() => Number(props.schedule.total_amount) + serviceFee.value)
+const baseAmount = computed(() => Number(props.schedule.total_amount) || 0)
+const platformFee = (paymentType) => parseFloat((baseAmount.value * (platformFees.value[paymentType] ?? 0)).toFixed(2))
+const pixTotal = computed(() => parseFloat((baseAmount.value + platformFee('pix')).toFixed(2)))
+const creditCardTotal = computed(() => parseFloat((baseAmount.value + platformFee('credit_card')).toFixed(2)))
+const pixDiscount = computed(() => Math.max(0, parseFloat((creditCardTotal.value - pixTotal.value).toFixed(2))))
 
 const onGoToPayment = () => {
   const hasValidPixPayment = !!paymentStore.getValidPixPayment(props.schedule.id)
@@ -105,7 +116,7 @@ const onGoToPayment = () => {
     component: hasValidPixPayment ? SchedulePaymentPixDialog : SchedulePaymentDialog,
     componentProps: {
       schedule: props.schedule,
-      total: total.value,
+      ...(hasValidPixPayment ? { total: pixTotal.value } : {}),
     },
   }).onOk(() => {
     onDialogOK()
@@ -166,6 +177,14 @@ const avatarStyle = computed(() => avatarColors[props.schedule.id % avatarColors
   color: #3a3a4a;
 }
 
+.discount-row {
+  color: #16845d;
+  background: rgba(22, 132, 93, 0.08);
+  border-radius: 8px;
+  margin: 2px 0;
+  padding: 5px 8px;
+}
+
 .payment-btn {
   height: 48px;
 }

+ 53 - 6
src/components/dashboard/SchedulePaymentDialog.vue

@@ -73,6 +73,22 @@
 
         <q-separator class="q-my-lg" />
 
+        <div class="payment-summary q-mb-lg">
+          <div class="row items-center justify-between q-mb-xs">
+            <span class="summary-label">{{ $t('dashboard_client.pending_schedules.detail_value') }}</span>
+            <span class="summary-value">{{ formatCurrency(baseAmount) }}</span>
+          </div>
+          <div class="row items-center justify-between q-mb-xs">
+            <span class="summary-label">{{ $t('dashboard_client.pending_schedules.detail_service_fee') }}</span>
+            <span class="summary-value">{{ formatCurrency(selectedPlatformFee) }}</span>
+          </div>
+          <q-separator class="q-my-sm" />
+          <div class="row items-center justify-between">
+            <span class="summary-total-label">{{ $t('dashboard_client.pending_schedules.detail_total') }}</span>
+            <span class="summary-total-value">{{ formatCurrency(selectedTotal) }}</span>
+          </div>
+        </div>
+
         <div class="row items-center q-mb-lg">
           <q-checkbox v-model="agreedToTerms" color="primary" keep-color />
           <span class="terms-text">
@@ -105,6 +121,8 @@ import { useI18n } from 'vue-i18n'
 import { userStore } from 'src/stores/user'
 import { usePaymentStore } from 'src/stores/payment'
 import { getClientPaymentMethods } from 'src/api/clientPaymentMethod'
+import { formatCurrency } from 'src/helpers/utils'
+import { usePaymentPlatformFees } from 'src/composables/usePaymentPlatformFees'
 import ProfilePaymentAddDialog from 'src/components/profile/ProfilePaymentAddDialog.vue'
 import SchedulePaymentPixDialog from './SchedulePaymentPixDialog.vue'
 import SchedulePaymentProcessingDialog from './SchedulePaymentProcessingDialog.vue'
@@ -114,10 +132,6 @@ const props = defineProps({
     type: Object,
     required: true,
   },
-  total: {
-    type: Number,
-    required: true,
-  },
 })
 
 defineEmits([...useDialogPluginComponent.emits])
@@ -127,12 +141,19 @@ const $q = useQuasar()
 const { t } = useI18n()
 const store = userStore()
 const paymentStore = usePaymentStore()
+const { platformFees, loadPlatformFees } = usePaymentPlatformFees()
 
 const selectedMethod = ref(null)
 const agreedToTerms = ref(false)
 const paymentMethods = ref([])
 const loadingCards = ref(false)
 
+const baseAmount = computed(() => Number(props.schedule.total_amount) || 0)
+const selectedPaymentType = computed(() => selectedMethod.value === 'pix' ? 'pix' : 'credit_card')
+const selectedPlatformFeeRate = computed(() => platformFees.value[selectedPaymentType.value] ?? platformFees.value.pix)
+const selectedPlatformFee = computed(() => parseFloat((baseAmount.value * selectedPlatformFeeRate.value).toFixed(2)))
+const selectedTotal = computed(() => parseFloat((baseAmount.value + selectedPlatformFee.value).toFixed(2)))
+
 const addressTypeLabel = computed(() => {
   const type = props.schedule.address?.address_type
   if (!type) return ''
@@ -158,6 +179,12 @@ const loadCards = async () => {
   loadingCards.value = true
   try {
     paymentMethods.value = await getClientPaymentMethods(store.user?.client_id)
+    const selectedCardId = String(selectedMethod.value || '').replace('card_', '')
+    const hasSelectedCard = paymentMethods.value.some((card) => String(card.id) === selectedCardId)
+
+    if (selectedMethod.value !== 'pix' && !hasSelectedCard) {
+      selectedMethod.value = paymentMethods.value.length > 0 ? `card_${paymentMethods.value[0].id}` : 'pix'
+    }
   } catch (e) {
     console.error(e)
   } finally {
@@ -177,7 +204,7 @@ const openAddCard = () => {
 const openPixPayment = () => {
   $q.dialog({
     component: SchedulePaymentPixDialog,
-    componentProps: { schedule: props.schedule, total: props.total },
+    componentProps: { schedule: props.schedule, total: selectedTotal.value },
   }).onOk(() => {
     onDialogOK()
   })
@@ -192,13 +219,15 @@ const onConfirm = () => {
 
   $q.dialog({
     component: SchedulePaymentProcessingDialog,
-    componentProps: { schedule: props.schedule, clientPaymentMethodId },
+    componentProps: { schedule: props.schedule, clientPaymentMethodId, total: selectedTotal.value },
   }).onOk(() => {
     onDialogOK()
   })
 }
 
 onMounted(() => {
+  loadPlatformFees().catch(() => {})
+
   if (paymentStore.getValidPixPayment(props.schedule.id)) {
     openPixPayment()
     return
@@ -253,6 +282,24 @@ onMounted(() => {
   margin-top: 2px;
 }
 
+.payment-summary {
+  border: 1px solid #e0e0e0;
+  border-radius: 10px;
+  background: #fff;
+  padding: 12px 16px;
+}
+
+.summary-label,
+.summary-value {
+  color: #5a5a6a;
+}
+
+.summary-total-label,
+.summary-total-value {
+  color: #3a3a4a;
+  font-weight: 700;
+}
+
 .saved-card-box {
   border: 1.5px solid #e0e0e0;
   border-radius: 12px;

+ 63 - 7
src/components/dashboard/SchedulePaymentPixDialog.vue

@@ -110,7 +110,7 @@
 import { computed, ref, onMounted, onUnmounted } from 'vue'
 import { useDialogPluginComponent, useQuasar, copyToClipboard } from 'quasar'
 import { formatCurrency } from 'src/helpers/utils'
-import { paySchedule } from 'src/api/payment'
+import { getSchedulePixPayment, paySchedule } from 'src/api/payment'
 import { usePaymentStore } from 'src/stores/payment'
 
 const props = defineProps({
@@ -151,7 +151,60 @@ const copyCode = async () => {
 
 const totalSeconds = ref(20 * 60)
 const countdown = ref('')
-let timer = null
+let countdownTimer = null
+let pollingTimer = null
+let pollingInFlight = false
+
+const stopPolling = () => {
+  clearInterval(pollingTimer)
+  pollingTimer = null
+}
+
+const applyPaymentStatus = (nextPayment) => {
+  if (!nextPayment) return
+
+  payment.value = nextPayment
+
+  if (nextPayment.status === 'paid') {
+    success.value = true
+    processing.value = false
+    paymentStore.clearPixPayment(props.schedule.id)
+    stopPolling()
+    return
+  }
+
+  if (['failed', 'cancelled'].includes(nextPayment.status)) {
+    processing.value = false
+    paymentStore.clearPixPayment(props.schedule.id)
+    stopPolling()
+    $q.notify({ type: 'negative', message: nextPayment.failure_message || 'Pagamento Pix não confirmado.' })
+    onDialogCancel()
+    return
+  }
+
+  paymentStore.setPixPayment(props.schedule.id, nextPayment)
+}
+
+const checkPaymentStatus = async () => {
+  if (pollingInFlight || success.value || totalSeconds.value <= 0) return
+
+  pollingInFlight = true
+  try {
+    applyPaymentStatus(await getSchedulePixPayment(props.schedule.id))
+    updateCountdown()
+  } catch (e) {
+    console.error('Erro ao verificar pagamento Pix:', e)
+  } finally {
+    pollingInFlight = false
+  }
+}
+
+const startPolling = () => {
+  stopPolling()
+  if (success.value || ['failed', 'cancelled'].includes(payment.value?.status) || totalSeconds.value <= 0) return
+  checkPaymentStatus()
+  pollingTimer = setInterval(checkPaymentStatus, 5000)
+}
 
 const updateCountdown = () => {
   if (pixExpiresAt.value) {
@@ -165,25 +218,27 @@ const updateCountdown = () => {
 
   if (pixExpiresAt.value && totalSeconds.value <= 0) {
     paymentStore.clearPixPayment(props.schedule.id)
+    stopPolling()
   }
 }
 
 onMounted(async () => {
   updateCountdown()
-  timer = setInterval(updateCountdown, 1000)
+  countdownTimer = setInterval(updateCountdown, 1000)
 
   try {
     const cachedPayment = paymentStore.getValidPixPayment(props.schedule.id)
 
     if (cachedPayment) {
-      payment.value = cachedPayment
+      applyPaymentStatus(cachedPayment)
       updateCountdown()
+      startPolling()
       return
     }
 
-    payment.value = await paySchedule(props.schedule.id, { payment_method: 'pix' })
-    paymentStore.setPixPayment(props.schedule.id, payment.value)
+    applyPaymentStatus(await paySchedule(props.schedule.id, { payment_method: 'pix' }))
     updateCountdown()
+    startPolling()
   } catch (e) {
     console.error('Erro ao criar pagamento Pix:', e)
     $q.notify({ type: 'negative', message: e?.response?.data?.message ?? 'Erro ao criar pagamento Pix.' })
@@ -194,7 +249,8 @@ onMounted(async () => {
 })
 
 onUnmounted(() => {
-  clearInterval(timer)
+  clearInterval(countdownTimer)
+  stopPolling()
 })
 </script>
 

+ 1 - 0
src/components/dashboard/SchedulePaymentProcessingDialog.vue

@@ -52,6 +52,7 @@ import { paySchedule } from 'src/api/payment'
 const props = defineProps({
   schedule: { type: Object, required: true },
   clientPaymentMethodId: { type: Number, required: true },
+  total: { type: Number, default: null },
 })
 
 defineEmits([...useDialogPluginComponent.emits])

+ 33 - 0
src/composables/usePaymentPlatformFees.js

@@ -0,0 +1,33 @@
+import { ref } from 'vue'
+import { getPaymentPlatformFees } from 'src/api/payment'
+
+const platformFees = ref({
+  pix: 0.11,
+  credit_card: 0.1457,
+})
+
+let loadingPromise = null
+
+export function usePaymentPlatformFees() {
+  const loadPlatformFees = async () => {
+    if (!loadingPromise) {
+      loadingPromise = getPaymentPlatformFees()
+        .then((fees) => {
+          platformFees.value = {
+            pix: Number(fees?.pix ?? platformFees.value.pix),
+            credit_card: Number(fees?.credit_card ?? platformFees.value.credit_card),
+          }
+        })
+        .finally(() => {
+          loadingPromise = null
+        })
+    }
+
+    await loadingPromise
+  }
+
+  return {
+    platformFees,
+    loadPlatformFees,
+  }
+}

+ 4 - 1
src/i18n/locales/en.json

@@ -510,6 +510,9 @@
       "detail_value": "Amount:",
       "detail_service_fee": "Service fee:",
       "detail_total": "Total:",
+      "detail_pix_total": "Pix total:",
+      "detail_credit_card_total": "Credit card total:",
+      "detail_pix_discount": "Pix discount:",
       "btn_payment": "go to payment",
       "btn_cancel": "Cancel request"
     },
@@ -831,4 +834,4 @@
       }
     }
   }
-}
+}

+ 4 - 1
src/i18n/locales/es.json

@@ -506,6 +506,9 @@
       "detail_value": "Valor:",
       "detail_service_fee": "Tasa de servicio:",
       "detail_total": "Total:",
+      "detail_pix_total": "Total en Pix:",
+      "detail_credit_card_total": "Total en tarjeta de crédito:",
+      "detail_pix_discount": "Descuento en Pix:",
       "btn_payment": "ir al pago",
       "btn_cancel": "Cancelar pedido"
     },
@@ -827,4 +830,4 @@
       }
     }
   }
-}
+}

+ 4 - 1
src/i18n/locales/pt.json

@@ -510,6 +510,9 @@
       "detail_value": "Valor:",
       "detail_service_fee": "Taxa de serviço:",
       "detail_total": "Total:",
+      "detail_pix_total": "Total no Pix:",
+      "detail_credit_card_total": "Total no cartão de crédito:",
+      "detail_pix_discount": "Desconto no Pix:",
       "btn_payment": "ir para o pagamento",
       "btn_cancel": "Cancelar pedido"
     },
@@ -844,4 +847,4 @@
     "unread": "Não lidas",
     "mark_all_read": " Marcar todas como lidas"
   }
-}
+}