feat: #44 fatura de cartão com data futura fica pendente até confirmação

- Nova tabela pending_bill_imports (migration 016)
- ImportHandler.Confirm: quando is_credit_card=true e payment_date > hoje,
  salva como pending em vez de inserir transações
- Novos endpoints: GET /pending-bills, POST /pending-bills/:id/confirm,
  DELETE /pending-bills/:id
- Dashboard inclui pending_bill_imports no payload
- Frontend: resultado "fatura salva como pendente" no ImportView
- AccountsView exibe widget de faturas pendentes com ações de confirmar/descartar
- dashboard_test: mock de PendingBillRepo + TransactionRepository

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-28 22:26:42 -03:00
co-authored by Claude Sonnet 4.6
parent 9742ae7469
commit 2b8524dcde
16 changed files with 453 additions and 32 deletions
+9
View File
@@ -34,6 +34,14 @@ export interface PendingIncome {
late: boolean
}
export interface PendingBillImport {
id: number
filename: string
payment_date: string
total: number
created_at: string
}
export interface DashboardData {
month: string
total_income: number
@@ -46,6 +54,7 @@ export interface DashboardData {
pending_recurring: number
pending_income_recurrings: PendingIncome[]
current_bills: CreditBill[]
pending_bill_imports: PendingBillImport[]
}
export const useDashboardStore = defineStore('dashboard', () => {
+5 -1
View File
@@ -15,6 +15,8 @@ export interface ImportResult {
imported: number
duplicates: number
errors: number
pending?: boolean
pending_bill?: { id: number; payment_date: string; total: number }
}
export interface CSVMapping {
@@ -63,7 +65,7 @@ export const useImportStore = defineStore('import', () => {
}
}
async function confirm() {
async function confirm(isCreditCard?: boolean, paymentDate?: string) {
loading.value = true
error.value = null
try {
@@ -74,6 +76,8 @@ export const useImportStore = defineStore('import', () => {
filename: filename.value,
rows: rows.value,
parse_error_count: parseErrors.value.length,
is_credit_card: isCreditCard ?? false,
payment_date: paymentDate ?? '',
}),
})
const data = await res.json()
+30
View File
@@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
import type { PendingBillImport } from './dashboard'
export const usePendingBillsStore = defineStore('pending_bills', () => {
const items = ref<PendingBillImport[]>([])
const loading = ref(false)
async function fetchAll() {
loading.value = true
try {
items.value = await api.get<PendingBillImport[]>('/pending-bills')
} finally {
loading.value = false
}
}
async function confirm(id: number) {
await api.post(`/pending-bills/${id}/confirm`, {})
items.value = items.value.filter((p) => p.id !== id)
}
async function discard(id: number) {
await api.delete(`/pending-bills/${id}`)
items.value = items.value.filter((p) => p.id !== id)
}
return { items, loading, fetchAll, confirm, discard }
})
+48 -1
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAccountsStore, type AccountInput } from '@/stores/accounts'
import { usePendingBillsStore } from '@/stores/pending_bills'
import NeonPanel from '@/components/NeonPanel.vue'
import CurrencyInput from '@/components/CurrencyInput.vue'
const store = useAccountsStore()
onMounted(() => store.fetchAll())
const pendingStore = usePendingBillsStore()
onMounted(() => { store.fetchAll(); pendingStore.fetchAll() })
const typeLabels: Record<string, string> = {
checking: 'Conta Corrente',
@@ -173,6 +175,34 @@ function totalBalance() {
</ul>
</NeonPanel>
<!-- Pending bill imports -->
<template v-if="pendingStore.items.length > 0">
<NeonPanel title="FATURAS PENDENTES DE CONFIRMAÇÃO" variant="danger">
<div class="fc-pending-list">
<div
v-for="p in pendingStore.items"
:key="p.id"
class="fc-pending-item"
>
<div class="fc-pending-item__info">
<span class="fc-body fc-pending-item__name">{{ p.filename }}</span>
<span class="fc-mono fc-pending-item__meta">
Vencimento: {{ p.payment_date }} · {{ fmt(p.total) }}
</span>
</div>
<div class="fc-pending-item__actions">
<button class="fc-btn fc-btn--sm fc-btn--primary" @click="pendingStore.confirm(p.id)">
CONFIRMAR PAGAMENTO
</button>
<button class="fc-btn fc-btn--sm fc-btn--ghost" @click="pendingStore.discard(p.id)">
DESCARTAR
</button>
</div>
</div>
</div>
</NeonPanel>
</template>
<!-- Current bills for credit accounts -->
<template v-for="a in store.accounts.filter(x => x.type === 'credit' && x.current_bill)" :key="'bill-' + a.id">
<NeonPanel :title="`FATURA · ${a.name}`" :variant="a.current_bill!.paid ? undefined : 'danger'">
@@ -327,4 +357,21 @@ function totalBalance() {
.fc-acc-item__balance { font-size: 16px; font-weight: 700; }
.fc-acc-item__actions { display: flex; gap: var(--fc-space-1); }
/* Pending bill imports */
.fc-pending-list { display: flex; flex-direction: column; gap: var(--fc-space-3); }
.fc-pending-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fc-space-3);
padding: 10px var(--fc-space-3);
border: 1px solid rgba(255,59,107,.3);
border-radius: var(--fc-radius);
flex-wrap: wrap;
}
.fc-pending-item__info { display: flex; flex-direction: column; gap: 4px; }
.fc-pending-item__name { font-size: 13px; }
.fc-pending-item__meta { font-size: 11px; color: var(--fc-text-dim); }
.fc-pending-item__actions { display: flex; gap: var(--fc-space-2); }
</style>
+21 -3
View File
@@ -76,8 +76,26 @@ function fmt(amount: number) {
<div class="fc-view">
<span class="fc-pixel fc-view__title">:: IMPORTAR EXTRATO</span>
<!-- Result banner -->
<NeonPanel v-if="store.result" variant="success" title="CONCLUÍDO">
<!-- Result banner: pending bill -->
<NeonPanel v-if="store.result?.pending" variant="success" title="FATURA SALVA COMO PENDENTE">
<div class="fc-import-result">
<span class="fc-mono fc-import-result__stat">
Vencimento: <span class="fc-text-gold">{{ store.result.pending_bill?.payment_date }}</span>
</span>
<span class="fc-mono fc-import-result__stat fc-text-green">
{{ fmt(store.result.pending_bill?.total ?? 0) }}
</span>
<span class="fc-mono fc-import-result__stat" style="color:var(--fc-text-dim);font-size:11px">
Confirme o pagamento em Contas quando pagar a fatura
</span>
<button class="fc-btn fc-btn--ghost" @click="store.reset(); selectedFile = null">
IMPORTAR OUTRO
</button>
</div>
</NeonPanel>
<!-- Result banner: imported -->
<NeonPanel v-else-if="store.result" variant="success" title="CONCLUÍDO">
<div class="fc-import-result">
<span class="fc-mono fc-import-result__stat">
<span class="fc-text-green">{{ store.result.imported }}</span> importadas
@@ -243,7 +261,7 @@ function fmt(amount: number) {
<button
class="fc-btn fc-btn--primary"
:disabled="store.loading || newCount === 0"
@click="store.confirm()"
@click="store.confirm(csvMapping.all_expenses, csvMapping.all_expenses ? csvMapping.payment_date : '')"
>
{{ store.loading ? 'SALVANDO...' : `CONFIRMAR ${newCount} TRANSAÇÕES` }}
</button>