// Valores entre membros: uma obrigação, sem presumir uso de cartão ou desembolso do credor. const MEMBER_OPERATION_KINDS = { sale: 'Venda', service: 'Serviço', loan: 'Empréstimo', other: 'Outro motivo' }; function memberOperationCents(input) { const value = String(input).trim().replace(',', '.'); if (!/^\d+(\.\d{1,2})?$/.test(value)) return null; const [whole, fraction = ''] = value.split('.'); const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0')); return Number.isSafeInteger(cents) && cents > 0 ? cents : null; } function MemberOperationButton({ children, primary = false, danger = false, ...props }) { return ; } function MemberOperationPeriodFields({ form, set, busy }) { return <> {form.type === 'installment' && set('installments', e.target.value)} disabled={busy} required />} {form.type === 'fixed' &&
{form.durationChoice === 'custom' && set('durationMonths', e.target.value)} disabled={busy} required />}
} ; } function memberOperationPeriodSummary(form) { const cents = memberOperationCents(form.amount); if (form.type === 'installment') { const n = Number(form.installments); if (!cents || !Number.isInteger(n) || n < 2 || n > 60) return 'Informe o valor total e de 2 a 60 parcelas.'; const base = Math.floor(cents / n), remainder = cents % n; const parts = []; if (remainder) parts.push(`${remainder} parcela${remainder > 1 ? 's' : ''} de ${fmtBRL((base + 1) / 100)}`); if (n - remainder) parts.push(`${n - remainder} parcela${n - remainder > 1 ? 's' : ''} de ${fmtBRL(base / 100)}`); return `${n} parcelas mensais: ${parts.join(' + ')}. Total ${fmtBRL(cents / 100)}.`; } if (form.type === 'fixed') { const frequency = form.frequency === 'weekly' ? 'semana' : form.frequency === 'biweekly' ? 'duas semanas' : 'mês'; if (form.durationChoice === 'open') return `${fmtBRL((cents || 0) / 100)} a cada ${frequency}, sem data de término. As próximas ocorrências são mantidas até encerrar a série.`; const count = fixedOccurrenceCount(form.frequency, form.durationChoice === 'custom' ? form.durationMonths : form.durationChoice); return `${fmtBRL((cents || 0) / 100)} a cada ${frequency}${count ? `, ${count} ocorrências` : ', escolha a duração'}.`; } return `Obrigação única de ${fmtBRL((cents || 0) / 100)}, sem meio de pagamento.`; } function MemberOperationForm({ expense, initialDraft = {}, creditorLocked = false, conversionEligibility, onClose, onSaved, onBusyChange = () => {} }) { const { currentMember, members, createMemberOperation, convertExpenseToMemberOperation } = useApp(); const [form, setForm] = React.useState({ description: expense?.desc || initialDraft.desc || '', amount: expense ? String(expense.value) : String(initialDraft.value || ''), date: expense?.date || initialDraft.date || todayLocalISO(), dueDate: '', kind: 'other', debtorId: conversionEligibility?.debtorId || expense?.responsibleMemberId || (initialDraft.responsibleMemberId !== currentMember.id ? initialDraft.responsibleMemberId : '') || (creditorLocked ? '' : currentMember.id), creditorId: conversionEligibility?.creditorId || (expense || creditorLocked ? currentMember.id : ''), type: initialDraft.type || 'single', installments: initialDraft.installments || 2, frequency: initialDraft.frequency || 'monthly', durationChoice: initialDraft.durationChoice || '', durationMonths: initialDraft.durationMonths || '', scope: conversionEligibility ? (['occurrence','future','series'].find(scope => conversionEligibility.scopes?.[scope]?.eligible) || 'occurrence') : 'occurrence' }); const [error, setError] = React.useState(''); const [busy, setBusy] = React.useState(false); const [confirmNoPaymentUse, setConfirmNoPaymentUse] = React.useState(false); const [confirmPreserveReceipts, setConfirmPreserveReceipts] = React.useState(false); const set = (key, value) => { setForm(previous => ({ ...previous, [key]: value })); if (key === 'scope') setConfirmPreserveReceipts(false); setError(''); }; const participant = form.debtorId === currentMember.id || form.creditorId === currentMember.id; const selectedScope = conversionEligibility?.scopes?.[form.scope]; const selectedOccurrences = selectedScope?.occurrences || []; const selectedCount = selectedScope?.count ?? selectedOccurrences.length; const selectedTotalCents = selectedScope?.amountCents ?? 0; async function save(event) { event.preventDefault(); if (busy) return; if (expense && (!conversionEligibility?.eligible || !conversionEligibility.scopes?.[form.scope]?.eligible)) return setError(conversionEligibility?.scopes?.[form.scope]?.reason || conversionEligibility?.reason || 'Esta conversão não é permitida.'); const amountCents = memberOperationCents(form.amount); if (!form.description.trim() || !amountCents || !form.date || !form.debtorId || !form.creditorId) return setError('Preencha descrição, valor, data e as duas pessoas.'); if (form.debtorId === form.creditorId) return setError('Quem paga e quem recebe devem ser pessoas diferentes.'); if (expense && selectedScope?.requiresPreserveReceiptsConfirmation && !confirmPreserveReceipts) return setError('Confirme a preservação dos recebimentos existentes.'); if (expense && !confirmNoPaymentUse) return setError('Confirme que não houve compra real no meio de pagamento do credor.'); if (!participant) return setError('Você precisa ser quem paga ou quem recebe este valor.'); if (!expense && form.type === 'installment' && (!Number.isInteger(Number(form.installments)) || Number(form.installments) < 2 || Number(form.installments) > 60)) return setError('Informe de 2 a 60 parcelas.'); if (!expense && form.type === 'installment' && amountCents < Number(form.installments)) return setError('O total deve permitir ao menos R$ 0,01 por parcela.'); if (!expense && form.type === 'fixed') { const issue = validateFixedDuration(form.frequency, form.durationChoice, form.durationMonths); if (issue) return setError(issue); } const period = { type: form.type }; if (form.type === 'installment') period.installments = Number(form.installments); if (form.type === 'fixed') { period.frequency = form.frequency; period.openEnded = form.durationChoice === 'open'; if (!period.openEnded) period.occurrenceCount = fixedOccurrenceCount(form.frequency, form.durationChoice === 'custom' ? form.durationMonths : form.durationChoice); } setBusy(true); onBusyChange(true); setError(''); try { const result = expense ? await convertExpenseToMemberOperation(expense.id, { debtorId: form.debtorId, creditorId: form.creditorId, kind: form.kind, scope: form.scope, confirmNoPaymentUse: true, ...(selectedScope.requiresPreserveReceiptsConfirmation ? { confirmPreserveReceipts: true } : {}) }) : await createMemberOperation({ description: form.description.trim(), amountCents, date: form.date, dueDate: form.dueDate || undefined, kind: form.kind, debtorId: form.debtorId, creditorId: form.creditorId, ...period }); if (!result?.success) { setError(result?.message || 'Não foi possível salvar. Tente novamente.'); return; } onSaved?.(result.operation); onClose(); } catch (failure) { setError(failure.message || 'Falha de conexão. Tente novamente.'); } finally { setBusy(false); onBusyChange(false); } } const name = id => members.find(member => member.id === id)?.name || 'Selecione uma pessoa'; return
{creditorLocked &&
Este acerto cria uma cobrança a receber de outro membro. Você é quem recebe; o responsável é quem paga. Não confirma pagamento nem usa cartão ou conta.
} {expense &&
A conversão substitui os lançamentos selecionados e seus reembolsos por obrigações diretas. Datas e valores reais serão preservados; o credor não terá débito em seu meio de pagamento. Despesas pagas, faturas e acertos vinculados continuam protegidos. Recebimentos diretos válidos serão preservados com sua confirmação. Uma seleção insegura será recusada por completo.
} set('description', e.target.value)} disabled={busy || !!expense} maxLength={200} required />
{!expense && }
set('amount', e.target.value)} disabled={busy || !!expense} required /> set('date', e.target.value)} disabled={busy || !!expense} required />
{!expense && set('dueDate', e.target.value)} disabled={busy} />} {expense?.seriesId && } {expense && conversionEligibility &&
A conversão usa os valores originais. Alterações no modo Despesa não são aplicadas à conversão.{Object.entries(conversionEligibility.scopes || {}).filter(([,scope]) => !scope.eligible && scope.reason).map(([key,scope]) =>
{key === 'future' ? 'Esta e as futuras' : key === 'series' ? 'Toda a série' : 'Somente esta ocorrência'}: {scope.reason}
)}
}
{name(form.debtorId)}{expense && selectedScope?.requiresPreserveReceiptsConfirmation ? ' tem uma obrigação registrada com ' : ' deve a '}{name(form.creditorId)}. {expense ? {selectedCount} ocorrência(s) selecionada(s); valor total deste escopo: {fmtBRL(selectedTotalCents / 100)}. Cada obrigação mantém seu vencimento e valor original. A prévia inclui somente ocorrências já lançadas. : {memberOperationPeriodSummary(form)}} A despesa pertence somente a quem paga; a receita pertence somente a quem recebe. {expense && selectedScope?.requiresPreserveReceiptsConfirmation ? (selectedScope.remainingCents > 0 ? 'Apenas o saldo em aberto precisa de um novo recebimento.' : 'Este escopo já está quitado; nenhum novo recebimento é necessário.') : 'O recebimento será confirmado depois pelo credor.'}
{expense && selectedScope?.requiresPreserveReceiptsConfirmation &&
Recebimentos já registrados: {fmtBRL(selectedScope.receivedCents / 100)}. Esses valores e suas datas serão preservados. Saldo que continua em aberto: {fmtBRL(selectedScope.remainingCents / 100)}. Não reabra nem registre novamente esses recebimentos.
} {expense && selectedScope?.requiresPreserveReceiptsConfirmation && } {expense && } {error &&
{error}
}
Cancelar{busy ? 'Salvando…' : expense ? 'Confirmar conversão' : creditorLocked ? 'Salvar acerto entre membros' : 'Criar valor entre membros'}
; } function MemberOperationReceiptForm({ operation, onClose, onBusyChange = () => {} }) { const { recordMemberOperationReceipt } = useApp(); const [amount, setAmount] = React.useState((operation.remainingCents / 100).toFixed(2)); const [date, setDate] = React.useState(todayLocalISO()); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(''); async function save(event) { event.preventDefault(); if (busy) return; const amountCents = memberOperationCents(amount); if (!amountCents || amountCents > operation.remainingCents) return setError('O recebimento deve ser maior que zero e não pode ultrapassar o valor em aberto.'); setBusy(true); onBusyChange(true); setError(''); try { const result = await recordMemberOperationReceipt(operation.id, { amountCents, date }); if (!result?.success) setError(result?.message || 'Não foi possível registrar.'); else onClose(); } catch (failure) { setError(failure.message || 'Falha de conexão. Tente novamente.'); } finally { setBusy(false); onBusyChange(false); } } return

Confirme somente o dinheiro que você já recebeu. Em aberto: {fmtBRL(operation.remainingCents / 100)}.

setAmount(e.target.value)} disabled={busy} required /> setDate(e.target.value)} disabled={busy} required />{error &&
{error}
}{busy ? 'Registrando…' : 'Confirmar recebimento'}
; } function MemberOperations() { const { currentMember, memberOperations = [], memberOperationsLoading, memberOperationsError, refreshMemberOperations, cancelMemberOperation, reverseMemberOperationReceipt, closeMemberOperationSeries } = useApp(); const [showForm, setShowForm] = React.useState(false); const [formBusy, setFormBusy] = React.useState(false); const [receipt, setReceipt] = React.useState(null); const [receiptBusy, setReceiptBusy] = React.useState(false); const [confirmation, setConfirmation] = React.useState(null); const [reason, setReason] = React.useState(''); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(''); const [filter, setFilter] = React.useState('open'); React.useEffect(() => { refreshMemberOperations().catch(() => {}); }, []); const mine = memberOperations.filter(operation => operation.debtorId === currentMember.id || operation.creditorId === currentMember.id); const shown = mine.filter(operation => filter === 'all' || (operation.status !== 'cancelled' && operation.remainingCents > 0)); async function retry() { try { await refreshMemberOperations(); } catch (_) {} } async function confirm(event) { event.preventDefault(); if (busy || !reason.trim()) return; setBusy(true); setError(''); try { const result = confirmation.seriesId ? await closeMemberOperationSeries(confirmation.seriesId, { reason: reason.trim() }) : confirmation.receiptId ? await reverseMemberOperationReceipt(confirmation.operation.id, confirmation.receiptId, { reason: reason.trim() }) : await cancelMemberOperation(confirmation.operation.id, { reason: reason.trim() }); if (!result?.success) setError(result?.message || 'Não foi possível concluir.'); else { setConfirmation(null); setReason(''); } } catch (failure) { setError(failure.message || 'Falha de conexão. Tente novamente.'); } finally { setBusy(false); } } const amountLine = (label, cents) =>
{label}{fmtBRL(cents / 100)}
; return

Valores entre membros

Vendas, serviços, empréstimos e outros valores a pagar ou receber, sem uso de cartão.

setShowForm(true)}>Novo valor

Cada lançamento tem uma origem única. Quem paga vê a despesa; quem recebe vê a receita prevista. Somente o credor confirma o recebimento, inclusive parcial. Os valores abaixo abrangem todos os meses.

setFilter('open')}>Em aberto setFilter('all')}>Todos
{memberOperationsError &&
{typeof memberOperationsError === 'string' ? memberOperationsError : 'Não foi possível carregar os valores.'}
Tentar novamente
} {memberOperationsLoading &&
Carregando valores entre membros…
} {!memberOperationsLoading && !memberOperationsError && !shown.length && } {shown.map(operation => { const creditor = operation.creditorId === currentMember.id; const cancelled = operation.status === 'cancelled'; const partner = creditor ? operation.debtor?.name : operation.creditor?.name; const status = cancelled ? 'Cancelado' : operation.remainingCents === 0 ? 'Quitado' : operation.receivedCents > 0 ? 'Parcialmente recebido' : 'Em aberto'; return
{operation.description}{creditor ? 'Você recebe de' : 'Você paga a'} {partner || 'membro da família'}
{MEMBER_OPERATION_KINDS[operation.kind] || 'Outro motivo'}{operation.seriesId && • {operation.type === 'installment' || operation.series?.type === 'installment' ? `Parcela ${operation.occurrenceIndex || operation.currentInstallment || 1}/${operation.installments || operation.series?.installments || '—'}` : `Fixa · ocorrência ${operation.occurrenceIndex || 1}`}}• {parseLocalDate(operation.date).toLocaleDateString('pt-BR')}{operation.dueDate && • Vence {parseLocalDate(operation.dueDate).toLocaleDateString('pt-BR')}}{status}
{amountLine('Valor original', operation.amountCents)}{amountLine('Recebido', operation.receivedCents)}{amountLine(cancelled ? 'Obrigação cancelada' : 'Em aberto', cancelled ? 0 : operation.remainingCents)}
{creditor && !cancelled && operation.remainingCents > 0 && setReceipt(operation)}>Registrar recebimento}{operation.createdById === currentMember.id && operation.series?.openEnded && !operation.series?.closedAt && shown.find(item => item.seriesId === operation.seriesId)?.id === operation.id && { setConfirmation({ operation, seriesId: operation.seriesId }); setReason(''); setError(''); }}>Parar renovação}{operation.createdById === currentMember.id && !cancelled && operation.receivedCents === 0 && { setConfirmation({ operation }); setReason(''); setError(''); }}>Cancelar obrigação}
{!!operation.receipts?.length &&
Histórico de recebimentos{operation.receipts.map(item =>
{fmtBRL(item.amountCents / 100)} · {parseLocalDate(item.date).toLocaleDateString('pt-BR')}{item.reversedAt && ' · Estornado'}{item.sourceIncomeId && Recebimento preservado da origem}
{item.settlementId && Confirmado pelo Balancete}{creditor && !item.reversedAt && !item.settlementId && !cancelled && { setConfirmation({ operation, receiptId: item.id }); setReason(''); setError(''); }}>Estornar}
)}
}
; })} { if (!formBusy) setShowForm(false); }} title="Novo valor entre membros">{showForm && setShowForm(false)} onBusyChange={setFormBusy} />} { if (!receiptBusy) setReceipt(null); }} title="Registrar recebimento">{receipt && setReceipt(null)} />} { if (!busy) setConfirmation(null); }} title={confirmation?.seriesId ? 'Parar renovação' : confirmation?.receiptId ? 'Estornar recebimento' : 'Cancelar obrigação'}>{confirmation &&

{confirmation.seriesId ? 'As cobranças já lançadas continuam; novas ocorrências deixam de ser geradas. Para corrigir uma cobrança existente, cancele essa ocorrência.' : confirmation.receiptId ? 'O valor deste recebimento voltará a ficar em aberto para as duas pessoas. O histórico será preservado.' : 'Esta obrigação deixará de contar como despesa e receita para os envolvidos. O histórico será preservado.'}

setReason(e.target.value)} disabled={busy} maxLength={200} required />{error &&
{error}
}{busy ? 'Processando…' : 'Confirmar'}
}
; } Object.assign(window, { MemberOperations, MemberOperationForm, memberOperationCents, memberOperationPeriodSummary });