@fayz-ai/plugin-financial 0.21.2 → 0.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import * as React25 from 'react';
2
- import React25__default, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
1
+ import * as React26 from 'react';
2
+ import React26__default, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
3
3
  import { recordRailFields, RECORD_GROUP, activeFacet, createConnectionStore, connectionSettings, connectionStatus, settingsStatus, connectionRuns, registerTranslations, getSupabaseClientOptional, createSafeDataProvider, registerMessageDefaults, reportDataError, useTranslation, createPendingCommand, useUnitMode, useDataChanged, countByTenant, tl, resolveDataProvider, useActiveUnitId, getActiveTenantId, emitDataChanged, describeError, exportCSV } from '@fayz-ai/core';
4
- import { INLINE_LINK_TONE, SearchSelect, ModalTask, toast, Button, useInModalTaskHost, useSaveBar, tourAnchor, DatePicker, SubpageHeader, defineKpiWidget, defineChartWidget, defineCustomWidget, defineTableWidget, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, DataTable, Card, CardContent, Skeleton, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MultiSelect, PageTransition, ModulePage, Badge, TooltipProvider, ConfirmDialog, CurrencyInput, Input, PageHeaderActions, ChoiceSelect, ModalFooter, Hint, PageTabs, Tooltip, TooltipTrigger, TooltipContent, Checkbox, Popover, PopoverTrigger, PopoverContent, openPrintWindow, printDocument, DashboardCanvas, ComingSoon, StatTiles, ListView, DateRangeControl, FilterPanel, FilterPanelField, SegmentedControl, RequiredFilter, FilterMenuRow, ChoiceList, ModalFlow, Switch, WizardSteps, KpiCard, ChartWidget, CardHeader, CardTitle, TableWidget, useDashboardNavigate, GoldCard } from '@fayz-ai/ui';
4
+ import { INLINE_LINK_TONE, SearchSelect, ModalTask, toast, Button, useInModalTaskHost, useSaveBar, tourAnchor, DatePicker, SubpageHeader, defineKpiWidget, defineChartWidget, defineCustomWidget, defineTableWidget, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, DataTable, Card, CardContent, Skeleton, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, MultiSelect, PageTransition, ModulePage, Badge, TooltipProvider, ConfirmDialog, CurrencyInput, Input, PageHeaderActions, ChoiceSelect, ModalFooter, Checkbox, SegmentedControl, Hint, PageTabs, Tooltip, TooltipTrigger, TooltipContent, Popover, PopoverTrigger, PopoverContent, openPrintWindow, printDocument, DashboardCanvas, ComingSoon, StatTiles, ListView, DateRangeControl, FilterPanel, FilterPanelField, RequiredFilter, FilterMenuRow, ChoiceList, ModalFlow, Switch, WizardSteps, KpiCard, ChartWidget, CardHeader, CardTitle, TableWidget, useDashboardNavigate, GoldCard } from '@fayz-ai/ui';
5
5
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
6
  import { createPluginContext, fayzConnectorOAuth, formatCurrency, RightRailPage, createCrudPage, PluginSettingsPanel, dedup, useUnitFilter, useLimitGuard, usePermissionOptional, useLookupCreate, OrderFormSkeleton, WidgetSlot, OrderFormBlock, OrderFormView, OrderFormItemsCard, OrderFormItemRow, adminNavigateTo, OrderFormAddItemStep, useUnitStore, OrderDetailSkeleton, PageActions, OrderDetailBlock, OrderDetailView, PersonLink, useTenantPluginSettings, useModuleNavigation, usePluginOnboardingGate, useModuleNavAccess, PluginOnboardingGateSkeleton, ModuleActionBar, SettingsGroup, ToggleRow, createArchetypeLookup, invalidateLimit, useAgentSurface, parseViewId, EntitlementGate, UpgradePrompt, useChatStore, revealPage, useAuth, GetStarted, resolveEntityRoute, PermissionGate, createOneShotAsk, askAI, requestChatUpload, toInlineFile, shrinkForVision } from '@fayz-ai/admin';
7
7
  import { Landmark, CircleDollarSign, FileText, TriangleAlert, Loader2, Check, X, MoreVertical, Ban, CircleDashed, CircleAlert, CircleCheckBig, CircleEllipsis, Pencil, Receipt, Gift, Briefcase, Package, CalendarDays, ExternalLink, DollarSign, LayoutList, Layers, Paperclip, Ticket, FileCheck, ArrowRightLeft, CreditCard, QrCode, Banknote, Undo2, Wallet, Plus, AlertTriangle, Filter, TrendingUp, Copy, Trash2, Search, ChevronsUpDown, ChevronsDownUp, ChevronRight, Building2, Image, UploadCloud, Calendar, User, Unlink, ChevronDown, Eye, Inbox, Clock, ClipboardCheck, BookOpen, Info, FolderOpen, Folder, RotateCcw, Settings, ArrowLeft, ChevronLeft, Calculator, Square, Play, ChevronUp, Printer, CalendarClock, BadgeCheck, Target, ArrowRight, Users, RefreshCw, Download, Camera, Sparkles, Link2, FileSearch, CheckCheck, Pause, ShoppingCart, Car, Home, Utensils, Zap, Film, HeartPulse, Dumbbell, Music, ShoppingBag, FileDown, ArrowUpRight, ArrowDownRight, EyeOff } from 'lucide-react';
@@ -908,9 +908,9 @@ function startReceiptCapture(labels) {
908
908
  });
909
909
  }
910
910
  function ReceiptAgentSurface({ onOpenDraft }) {
911
- const live = React25.useRef(onOpenDraft);
911
+ const live = React26.useRef(onOpenDraft);
912
912
  live.current = onOpenDraft;
913
- const contribution = React25.useMemo(() => ({
913
+ const contribution = React26.useMemo(() => ({
914
914
  id: "financial.payables.receipt",
915
915
  pluginId: "financial",
916
916
  role: "finance",
@@ -1404,22 +1404,22 @@ function lineFromInvoice(invoice, payee) {
1404
1404
  var openAmount = (invoice) => Math.max(0, invoice.totalAmount - invoice.paidAmount);
1405
1405
  function CnabRemittanceModal({ invoices, bankAccounts, provider, onClose }) {
1406
1406
  const { currency } = useFinancialConfig();
1407
- const [step, setStep] = React25__default.useState(0);
1408
- const [bankAccountId, setBankAccountId] = React25__default.useState("");
1409
- const [draft, setDraft] = React25__default.useState(null);
1410
- const [context, setContext] = React25__default.useState(null);
1411
- const [companyName, setCompanyName] = React25__default.useState("");
1412
- const [companyDocument, setCompanyDocument] = React25__default.useState("");
1413
- const [lines, setLines] = React25__default.useState([]);
1414
- const [companyUnsaved, setCompanyUnsaved] = React25__default.useState(false);
1415
- const [loadingContext, setLoadingContext] = React25__default.useState(false);
1416
- const [busy, setBusy] = React25__default.useState(false);
1417
- const [error, setError] = React25__default.useState("");
1407
+ const [step, setStep] = React26__default.useState(0);
1408
+ const [bankAccountId, setBankAccountId] = React26__default.useState("");
1409
+ const [draft, setDraft] = React26__default.useState(null);
1410
+ const [context, setContext] = React26__default.useState(null);
1411
+ const [companyName, setCompanyName] = React26__default.useState("");
1412
+ const [companyDocument, setCompanyDocument] = React26__default.useState("");
1413
+ const [lines, setLines] = React26__default.useState([]);
1414
+ const [companyUnsaved, setCompanyUnsaved] = React26__default.useState(false);
1415
+ const [loadingContext, setLoadingContext] = React26__default.useState(false);
1416
+ const [busy, setBusy] = React26__default.useState(false);
1417
+ const [error, setError] = React26__default.useState("");
1418
1418
  const eligibleAccounts2 = bankAccounts.filter((account2) => account2.accountType === "bank" && account2.isActive);
1419
1419
  const total = invoices.reduce((sum, invoice) => sum + openAmount(invoice), 0);
1420
1420
  const account = eligibleAccounts2.find((candidate) => candidate.id === bankAccountId);
1421
1421
  const missingBankData = draft ? missingFields(draft, companyDocument) : [];
1422
- const items = React25__default.useMemo(() => lines.map((line) => ({
1422
+ const items = React26__default.useMemo(() => lines.map((line) => ({
1423
1423
  reference: line.invoice.id,
1424
1424
  amount: openAmount(line.invoice),
1425
1425
  paymentDate: line.paymentDate,
@@ -1444,8 +1444,8 @@ function CnabRemittanceModal({ invoices, bankAccounts, provider, onClose }) {
1444
1444
  postalCode: line.payee.postalCode
1445
1445
  }
1446
1446
  })), [lines]);
1447
- const gaps = React25__default.useMemo(() => cnabPayeeGaps(items), [items]);
1448
- const batches = React25__default.useMemo(() => {
1447
+ const gaps = React26__default.useMemo(() => cnabPayeeGaps(items), [items]);
1448
+ const batches = React26__default.useMemo(() => {
1449
1449
  const grouped = /* @__PURE__ */ new Map();
1450
1450
  for (const line of lines) {
1451
1451
  const current = grouped.get(line.paymentForm) ?? { count: 0, total: 0 };
@@ -2066,7 +2066,7 @@ function FinancialCounterpartySearchSelect({
2066
2066
  onEdit,
2067
2067
  disabled
2068
2068
  }) {
2069
- const search = React25.useCallback(async (query) => {
2069
+ const search = React26.useCallback(async (query) => {
2070
2070
  const results = await lookup.search(query);
2071
2071
  return results.map((result) => ({
2072
2072
  id: result.id,
@@ -2179,6 +2179,7 @@ function useInvoiceColumns(currency, paymentMethods2, paymentMethodTypes, bankAc
2179
2179
  {
2180
2180
  id: "description",
2181
2181
  header: t2("financial.invoice.columnDescription"),
2182
+ meta: { mobile: "title" },
2182
2183
  cell: ({ row }) => (
2183
2184
  // Full cell width with the badge pinned right, so the statuses line up in
2184
2185
  // one column instead of trailing each name.
@@ -2506,8 +2507,8 @@ function InvoiceListView({ direction, onNew, onNewReceipt, onEdit }) {
2506
2507
  }, [currency, direction, t2]);
2507
2508
  const bulkActions = useMemo(() => {
2508
2509
  const actions = [
2509
- { id: "print", label: t2("financial.invoice.printSelected"), icon: Printer, onSelect: printSelected },
2510
- { id: "export", label: t2("financial.invoice.exportSelected"), icon: Download, onSelect: exportSelected }
2510
+ { id: "print", label: t2("financial.invoice.printSelected"), icon: Printer, desktopOnly: true, onSelect: printSelected },
2511
+ { id: "export", label: t2("financial.invoice.exportSelected"), icon: Download, desktopOnly: true, onSelect: exportSelected }
2511
2512
  ];
2512
2513
  if (!canEdit) return actions;
2513
2514
  if (direction === "debit") {
@@ -2794,6 +2795,9 @@ function InvoiceListView({ direction, onNew, onNewReceipt, onEdit }) {
2794
2795
  description: t2("financial.invoice.importPayrollSoon"),
2795
2796
  icon: Users,
2796
2797
  intent: "secondary",
2798
+ // Importing a payroll inserts payables: without this the set is
2799
+ // no longer all-create and the FAB falls back to the neutral "⋮".
2800
+ creates: true,
2797
2801
  disabled: true,
2798
2802
  onSelect: () => {
2799
2803
  }
@@ -3951,9 +3955,9 @@ var SUBMIT_DRAFT = {
3951
3955
  parameters: { type: "object", properties: {} }
3952
3956
  };
3953
3957
  function PayableDraftSurface({ config, draft }) {
3954
- const live = React25.useRef(draft);
3958
+ const live = React26.useRef(draft);
3955
3959
  live.current = draft;
3956
- const lookup = React25.useMemo(
3960
+ const lookup = React26.useMemo(
3957
3961
  () => config.contactLookup ?? createArchetypeLookup({
3958
3962
  archetype: "person",
3959
3963
  kind: "supplier",
@@ -3962,7 +3966,7 @@ function PayableDraftSurface({ config, draft }) {
3962
3966
  }),
3963
3967
  [config.contactLookup]
3964
3968
  );
3965
- React25.useEffect(() => {
3969
+ React26.useEffect(() => {
3966
3970
  liveDraft = {
3967
3971
  describe: () => live.current.describe(),
3968
3972
  setSupplier: (name, id) => live.current.setSupplier(name, id),
@@ -3973,7 +3977,7 @@ function PayableDraftSurface({ config, draft }) {
3973
3977
  liveDraft = null;
3974
3978
  };
3975
3979
  }, []);
3976
- const contribution = React25.useMemo(() => ({
3980
+ const contribution = React26.useMemo(() => ({
3977
3981
  id: "financial.payables.draft",
3978
3982
  pluginId: "financial",
3979
3983
  role: "finance",
@@ -4048,7 +4052,7 @@ function PayableDraftSurface({ config, draft }) {
4048
4052
  }
4049
4053
  function createFinancialAgentMount(deps) {
4050
4054
  const FinancialAgentMount = () => {
4051
- const contribution = React25.useMemo(() => ({
4055
+ const contribution = React26.useMemo(() => ({
4052
4056
  id: "financial.payables.global",
4053
4057
  pluginId: "financial",
4054
4058
  role: "finance",
@@ -4558,10 +4562,10 @@ function PaymentModal({ movement, receiptUrl, onClose, onPaid }) {
4558
4562
  const payMovement = useFinancialStore((s) => s.payMovement);
4559
4563
  const remaining = movement.amount - movement.paidAmount;
4560
4564
  const [saving, setSaving] = useState(false);
4561
- const command = React25__default.useRef(createPendingCommand(() => createIdempotencyKey("financial:payment"), (input) => input.movementId));
4565
+ const command = React26__default.useRef(createPendingCommand(() => createIdempotencyKey("financial:payment"), (input) => input.movementId));
4562
4566
  const [pending2, setPending] = useState(false);
4563
4567
  const [visible, setVisible] = useState(false);
4564
- React25__default.useEffect(() => {
4568
+ React26__default.useEffect(() => {
4565
4569
  requestAnimationFrame(() => setVisible(true));
4566
4570
  }, []);
4567
4571
  const source = movement.metadata ?? {};
@@ -4775,8 +4779,8 @@ function InstallmentSourceFields({
4775
4779
  }
4776
4780
  var norm2 = (v) => v.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
4777
4781
  function AccountPicker({ accounts, value, onChange, disabled, title, placeholder, ariaLabel, clearLabel, className, onManage, manageLabel }) {
4778
- const [open, setOpen] = React25__default.useState(false);
4779
- const [q, setQ] = React25__default.useState("");
4782
+ const [open, setOpen] = React26__default.useState(false);
4783
+ const [q, setQ] = React26__default.useState("");
4780
4784
  const selected = accounts.find((a) => a.id === value);
4781
4785
  const list = q ? accounts.filter((a) => norm2(a.name).includes(norm2(q))) : accounts;
4782
4786
  const pick = (id) => {
@@ -4960,6 +4964,212 @@ function InstallmentReceiptButton({ onClick, title, attached, pending: pending2,
4960
4964
  }
4961
4965
  );
4962
4966
  }
4967
+ var DESTINATIONS = [
4968
+ { value: "devolver", label: "Devolver ao fornecedor" },
4969
+ { value: "perda", label: "Baixar como perda" },
4970
+ { value: "manter", label: "Manter no estoque" }
4971
+ ];
4972
+ function day(iso) {
4973
+ const [y, m, d] = iso.slice(0, 10).split("-");
4974
+ return `${d}/${m}/${y}`;
4975
+ }
4976
+ function CancelInvoiceDialog({ invoiceId, counterpartyName, totalAmount, items, preselectItemId, onClose, onCancelled }) {
4977
+ const { currency } = useFinancialConfig();
4978
+ const provider = useFinancialProvider();
4979
+ const [selected, setSelected] = useState(
4980
+ () => new Set(preselectItemId ? [preselectItemId] : items.map((i) => i.id))
4981
+ );
4982
+ const [reasons, setReasons] = useState(null);
4983
+ const [reasonId, setReasonId] = useState();
4984
+ const [detail, setDetail] = useState("");
4985
+ const [entries, setEntries] = useState(null);
4986
+ const [destinations, setDestinations] = useState({});
4987
+ const [busy, setBusy] = useState(false);
4988
+ const requestKey = useRef(createIdempotencyKey("financial:invoice-cancel-items"));
4989
+ useEffect(() => {
4990
+ let alive = true;
4991
+ Promise.all([
4992
+ provider.listCancellationReasons?.() ?? Promise.resolve([]),
4993
+ provider.getInvoiceStockEntries?.(invoiceId) ?? Promise.resolve([])
4994
+ ]).then(([r, e]) => {
4995
+ if (alive) {
4996
+ setReasons(r);
4997
+ setEntries(e);
4998
+ }
4999
+ }).catch((err) => {
5000
+ if (alive) {
5001
+ setReasons([]);
5002
+ setEntries([]);
5003
+ toast.error("N\xE3o foi poss\xEDvel carregar o cancelamento", { description: err?.message });
5004
+ }
5005
+ });
5006
+ return () => {
5007
+ alive = false;
5008
+ };
5009
+ }, [provider, invoiceId]);
5010
+ const whole = items.length > 0 && items.every((i) => selected.has(i.id));
5011
+ const amount = whole ? totalAmount : items.filter((i) => selected.has(i.id)).reduce((s, i) => s + i.totalAmount, 0);
5012
+ const stocked = useMemo(() => {
5013
+ const byItem = /* @__PURE__ */ new Map();
5014
+ for (const e of entries ?? []) {
5015
+ const cur = byItem.get(e.itemId);
5016
+ byItem.set(e.itemId, { entered: (cur?.entered ?? 0) + e.entered, enteredOn: cur?.enteredOn ?? e.enteredOn });
5017
+ }
5018
+ return byItem;
5019
+ }, [entries]);
5020
+ const stockedSelected = items.filter((i) => selected.has(i.id) && stocked.has(i.id));
5021
+ const missing = selected.size === 0 ? "Selecione pelo menos um item" : stockedSelected.find((i) => !destinations[i.id]?.destination) ? "Diga o que acontece com a mercadoria" : !reasonId ? "Escolha o motivo do cancelamento" : null;
5022
+ function toggle(id, on) {
5023
+ setSelected((prev) => {
5024
+ const next = new Set(prev);
5025
+ if (on) next.add(id);
5026
+ else next.delete(id);
5027
+ return next;
5028
+ });
5029
+ }
5030
+ async function submit() {
5031
+ if (missing || !reasonId || !provider.cancelInvoiceItems) return;
5032
+ setBusy(true);
5033
+ try {
5034
+ const result = await provider.cancelInvoiceItems({
5035
+ invoiceId,
5036
+ itemIds: whole ? [] : [...selected],
5037
+ reasonId,
5038
+ detail,
5039
+ stock: stockedSelected.map((i) => ({
5040
+ itemId: i.id,
5041
+ destination: destinations[i.id].destination,
5042
+ note: destinations[i.id]?.note
5043
+ })),
5044
+ idempotencyKey: requestKey.current
5045
+ });
5046
+ if (result.creditAmount > 0) {
5047
+ toast.success(result.whole ? "Conta cancelada" : "Itens cancelados", {
5048
+ description: `${formatCurrency(result.creditAmount, currency)} em pagamentos ficaram como cr\xE9dito${counterpartyName ? ` de ${counterpartyName}` : ""}.`
5049
+ });
5050
+ } else {
5051
+ toast.success(result.whole ? "Conta cancelada" : "Itens cancelados");
5052
+ }
5053
+ onCancelled();
5054
+ } catch (err) {
5055
+ toast.error("N\xE3o foi poss\xEDvel cancelar", { description: err?.message });
5056
+ requestKey.current = createIdempotencyKey("financial:invoice-cancel-items");
5057
+ } finally {
5058
+ setBusy(false);
5059
+ }
5060
+ }
5061
+ const loading = reasons === null || entries === null;
5062
+ return /* @__PURE__ */ jsxs(
5063
+ ModalTask,
5064
+ {
5065
+ open: true,
5066
+ title: /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-2 text-destructive", children: [
5067
+ /* @__PURE__ */ jsx(Ban, { className: "h-4 w-4" }),
5068
+ " Cancelar"
5069
+ ] }),
5070
+ width: "max-w-lg",
5071
+ onClose,
5072
+ children: [
5073
+ /* @__PURE__ */ jsxs("div", { className: "fz-container min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4", children: [
5074
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", children: [
5075
+ "Selecione o que cancelar. Pagamentos j\xE1 alocados ao que for cancelado ser\xE3o liberados como cr\xE9dito",
5076
+ counterpartyName ? ` de ${counterpartyName}` : "",
5077
+ "."
5078
+ ] }),
5079
+ items.length > 1 && /* @__PURE__ */ jsxs("label", { className: "flex cursor-pointer items-center gap-3 rounded-lg border border-destructive/30 bg-destructive-soft px-3 py-2.5 text-sm font-medium text-destructive", children: [
5080
+ /* @__PURE__ */ jsx(
5081
+ Checkbox,
5082
+ {
5083
+ checked: whole,
5084
+ indeterminate: !whole && selected.size > 0,
5085
+ color: "destructive",
5086
+ onChange: (on) => setSelected(on ? new Set(items.map((i) => i.id)) : /* @__PURE__ */ new Set())
5087
+ }
5088
+ ),
5089
+ "Cancelar a conta inteira"
5090
+ ] }),
5091
+ /* @__PURE__ */ jsx("div", { className: "space-y-2", children: items.map((it) => /* @__PURE__ */ jsxs("label", { className: "flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5", children: [
5092
+ /* @__PURE__ */ jsx(Checkbox, { checked: selected.has(it.id), onChange: (on) => toggle(it.id, on) }),
5093
+ /* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate text-sm", children: it.description }),
5094
+ loading ? /* @__PURE__ */ jsx(Skeleton, { className: "h-5 w-16" }) : stocked.has(it.id) && /* @__PURE__ */ jsx(Badge, { variant: "success", className: "shrink-0", children: "No estoque" }),
5095
+ /* @__PURE__ */ jsx("span", { className: "shrink-0 text-sm tabular-nums text-muted-foreground", children: formatCurrency(it.totalAmount, currency) })
5096
+ ] }, it.id)) }),
5097
+ stockedSelected.length > 0 && /* @__PURE__ */ jsxs("div", { className: "space-y-4 rounded-lg border border-warning/40 bg-warning-soft p-3", children: [
5098
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-warning-soft-foreground", children: "Estes itens deram entrada no estoque. Diga o que acontece com a mercadoria: o destino vale para o saldo que ainda est\xE1 na posi\xE7\xE3o; o que j\xE1 foi consumido fica registrado." }),
5099
+ stockedSelected.map((it) => {
5100
+ const info = stocked.get(it.id);
5101
+ const choice = destinations[it.id];
5102
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
5103
+ /* @__PURE__ */ jsxs("div", { children: [
5104
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-medium", children: it.description }),
5105
+ /* @__PURE__ */ jsxs("div", { className: "text-xs text-muted-foreground", children: [
5106
+ "Entrou no estoque: ",
5107
+ info.entered.toLocaleString("pt-BR"),
5108
+ " \xB7 ",
5109
+ day(info.enteredOn)
5110
+ ] })
5111
+ ] }),
5112
+ /* @__PURE__ */ jsx(
5113
+ SegmentedControl,
5114
+ {
5115
+ "aria-label": `Destino de ${it.description}`,
5116
+ options: DESTINATIONS,
5117
+ value: choice?.destination ?? "",
5118
+ onChange: (v) => setDestinations((d) => ({ ...d, [it.id]: { note: d[it.id]?.note ?? "", destination: v || void 0 } })),
5119
+ className: "flex-wrap"
5120
+ }
5121
+ ),
5122
+ /* @__PURE__ */ jsx(
5123
+ "input",
5124
+ {
5125
+ value: choice?.note ?? "",
5126
+ onChange: (e) => setDestinations((d) => ({ ...d, [it.id]: { destination: d[it.id]?.destination, note: e.target.value } })),
5127
+ placeholder: "Observa\xE7\xE3o (ex.: nota de devolu\xE7\xE3o 8226400)",
5128
+ className: "h-9 w-full rounded-input border border-input bg-card px-3 text-sm"
5129
+ }
5130
+ )
5131
+ ] }, it.id);
5132
+ })
5133
+ ] }),
5134
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between border-t pt-3 text-sm font-medium", children: [
5135
+ /* @__PURE__ */ jsx("span", { children: whole ? "Conta inteira ser\xE1 cancelada" : "Total a cancelar" }),
5136
+ /* @__PURE__ */ jsx("span", { className: "tabular-nums", children: formatCurrency(amount, currency) })
5137
+ ] }),
5138
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
5139
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-medium", children: "Motivo do cancelamento" }),
5140
+ reasons === null ? /* @__PURE__ */ jsx(Skeleton, { className: "h-9 w-full" }) : /* @__PURE__ */ jsx(
5141
+ ChoiceSelect,
5142
+ {
5143
+ "aria-label": "Motivo do cancelamento",
5144
+ options: reasons.map((r) => ({ value: r.id, label: r.name })),
5145
+ value: reasonId,
5146
+ onChange: setReasonId,
5147
+ placeholder: "Selecione um motivo..."
5148
+ }
5149
+ ),
5150
+ /* @__PURE__ */ jsx(
5151
+ "textarea",
5152
+ {
5153
+ value: detail,
5154
+ onChange: (e) => setDetail(e.target.value),
5155
+ placeholder: "Detalhe (opcional)...",
5156
+ className: "min-h-20 w-full resize-y rounded-input border border-input bg-card px-3 py-2 text-sm"
5157
+ }
5158
+ )
5159
+ ] })
5160
+ ] }),
5161
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center justify-end gap-2 border-t px-5 py-3", children: [
5162
+ missing && !loading && /* @__PURE__ */ jsx("span", { className: "mr-auto text-xs text-muted-foreground", children: missing }),
5163
+ /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: onClose, disabled: busy, children: "Voltar" }),
5164
+ /* @__PURE__ */ jsxs(Button, { variant: "destructive", size: "sm", onClick: submit, disabled: busy || loading || !!missing, children: [
5165
+ /* @__PURE__ */ jsx(Ban, { className: "h-3.5 w-3.5" }),
5166
+ busy ? "Cancelando\u2026" : whole ? "Cancelar conta" : "Cancelar itens"
5167
+ ] })
5168
+ ] })
5169
+ ]
5170
+ }
5171
+ );
5172
+ }
4963
5173
 
4964
5174
  // src/views/focusItem.ts
4965
5175
  var CHAVE = "fayz:financial:focus-item";
@@ -4984,9 +5194,9 @@ function consumirItemParaAbrir(invoiceId) {
4984
5194
  }
4985
5195
  }
4986
5196
  function useStockedItemTypes(itemTypes, entityLookups) {
4987
- const [empty, setEmpty] = React25__default.useState(null);
5197
+ const [empty, setEmpty] = React26__default.useState(null);
4988
5198
  const backed = itemTypes.filter((type) => entityLookups[type.value]?.count).map((type) => type.value).sort().join(",");
4989
- React25__default.useEffect(() => {
5199
+ React26__default.useEffect(() => {
4990
5200
  const kinds = backed ? backed.split(",") : [];
4991
5201
  if (!kinds.length) {
4992
5202
  setEmpty(null);
@@ -5007,7 +5217,7 @@ function useStockedItemTypes(itemTypes, entityLookups) {
5007
5217
  cancelled = true;
5008
5218
  };
5009
5219
  }, [backed]);
5010
- return React25__default.useMemo(
5220
+ return React26__default.useMemo(
5011
5221
  () => empty?.size ? itemTypes.filter((type) => !empty.has(type.value)) : itemTypes,
5012
5222
  [itemTypes, empty]
5013
5223
  );
@@ -5177,14 +5387,14 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
5177
5387
  };
5178
5388
  }, [giftCardsEnabled, provider, t2]);
5179
5389
  const entityLookups = useMemo(() => giftCardLookup ? { ...config.entityLookups, gift_card: giftCardLookup } : config.entityLookups, [config.entityLookups, giftCardLookup]);
5180
- const translatedItemTypes = React25__default.useMemo(
5390
+ const translatedItemTypes = React26__default.useMemo(
5181
5391
  () => itemTypesRaw.map((tp) => {
5182
5392
  const traduzido = t2(`financial.itemType.${tp.value}`);
5183
5393
  return traduzido === `financial.itemType.${tp.value}` ? tp : { ...tp, label: traduzido };
5184
5394
  }),
5185
5395
  [itemTypesRaw, t2]
5186
5396
  );
5187
- const offeredItemTypes = React25__default.useMemo(
5397
+ const offeredItemTypes = React26__default.useMemo(
5188
5398
  () => availableInvoiceItemTypes(
5189
5399
  giftCardsEnabled && !translatedItemTypes.some((type) => type.value === "gift_card") ? [...translatedItemTypes, { value: "gift_card", label: t2("financial.itemType.gift_card"), icon: "Gift" }] : translatedItemTypes,
5190
5400
  Object.keys(entityLookups),
@@ -5208,7 +5418,7 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
5208
5418
  const isEdit = !!editId;
5209
5419
  const [attachmentsOpen, setAttachmentsOpen] = useState(false);
5210
5420
  const [attachmentCount, setAttachmentCount] = useState(0);
5211
- const loadAttachmentCount = React25__default.useCallback(() => {
5421
+ const loadAttachmentCount = React26__default.useCallback(() => {
5212
5422
  if (!editId || !provider.getInvoiceAttachments) return;
5213
5423
  provider.getInvoiceAttachments(editId).then((list) => setAttachmentCount(list.length)).catch(() => {
5214
5424
  });
@@ -5306,16 +5516,17 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
5306
5516
  } : void 0);
5307
5517
  const [saving, setSaving] = useState(false);
5308
5518
  const [menuOpen, setMenuOpen] = useState(false);
5309
- const [confirmCancel, setConfirmCancel] = useState(false);
5310
- const [cancelling, setCancelling] = useState(false);
5311
- const [cancelReason, setCancelReason] = useState("");
5312
- const cancelRequestKey = useRef(createIdempotencyKey("financial:invoice-cancel"));
5519
+ const [cancelData, setCancelData] = useState(null);
5520
+ const [cancelledLines, setCancelledLines] = useState([]);
5313
5521
  const menuRef = useRef(null);
5314
- const cancelInvoice = useFinancialStore((s) => s.cancelInvoice);
5315
- function closeCancelDialog() {
5316
- setConfirmCancel(false);
5317
- setCancelReason("");
5318
- cancelRequestKey.current = createIdempotencyKey("financial:invoice-cancel");
5522
+ async function openCancelDialog() {
5523
+ if (!editId) return;
5524
+ try {
5525
+ const [inv, lines] = await Promise.all([provider.getInvoiceById(editId), provider.getInvoiceItems(editId)]);
5526
+ setCancelData({ items: lines.filter((l) => !l.cancelledAt), total: inv?.totalAmount ?? 0, name: inv?.contactName });
5527
+ } catch (err) {
5528
+ toast.error("N\xE3o foi poss\xEDvel abrir o cancelamento", { description: err?.message });
5529
+ }
5319
5530
  }
5320
5531
  useEffect(() => {
5321
5532
  if (!menuOpen) return;
@@ -5325,21 +5536,6 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
5325
5536
  document.addEventListener("mousedown", handleClick);
5326
5537
  return () => document.removeEventListener("mousedown", handleClick);
5327
5538
  }, [menuOpen]);
5328
- async function handleCancelInvoice() {
5329
- if (!editId) return;
5330
- setCancelling(true);
5331
- try {
5332
- await cancelInvoice({
5333
- invoiceId: editId,
5334
- reason: cancelReason,
5335
- idempotencyKey: cancelRequestKey.current
5336
- });
5337
- onSaved?.();
5338
- } catch {
5339
- } finally {
5340
- setCancelling(false);
5341
- }
5342
- }
5343
5539
  useEffect(() => {
5344
5540
  if (!editId || loaded) return;
5345
5541
  setLoading(true);
@@ -5366,7 +5562,9 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
5366
5562
  setSavedTotal(invoice.totalAmount);
5367
5563
  setFiscalNumber(String(invoice.metadata?.fiscalNumber ?? ""));
5368
5564
  setObservations(invoice.observations ?? "");
5369
- const invoiceItems = await provider.getInvoiceItems(editId);
5565
+ const allLines = await provider.getInvoiceItems(editId);
5566
+ const invoiceItems = allLines.filter((it) => !it.cancelledAt);
5567
+ setCancelledLines(allLines.filter((it) => !!it.cancelledAt));
5370
5568
  setSavedItemsKey(itemsKey(invoiceItems));
5371
5569
  const idsCarregados = [];
5372
5570
  setItems(invoiceItems.map((it) => {
@@ -5503,13 +5701,13 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
5503
5701
  if (isEdit || totalAmount <= 0 || installments.length > 0) return;
5504
5702
  generateInstallments(1);
5505
5703
  }, [isEdit, totalAmount, installments.length]);
5506
- const fromCard = React25__default.useMemo(
5704
+ const fromCard = React26__default.useMemo(
5507
5705
  () => fromCardId ? bankAccounts.find((a) => a.id === fromCardId) : void 0,
5508
5706
  [fromCardId, bankAccounts]
5509
5707
  );
5510
5708
  const cardLast4 = (fromCard?.accountNumber ?? "").replace(/\D/g, "").slice(-4);
5511
5709
  const nomeJaTemOsDigitos = !!cardLast4 && (fromCard?.name ?? "").replace(/\D/g, "").endsWith(cardLast4);
5512
- const cardMethodName = React25__default.useMemo(() => {
5710
+ const cardMethodName = React26__default.useMemo(() => {
5513
5711
  const id = installments[0]?.payMethodTypeId;
5514
5712
  const escolhida = id ? paymentMethodTypes.find((tp) => tp.id === id) : void 0;
5515
5713
  return (escolhida ?? paymentMethodTypes.find((tp) => tp.transactionType === "credit_card"))?.name;
@@ -5797,14 +5995,13 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
5797
5995
  "button",
5798
5996
  {
5799
5997
  onClick: () => {
5800
- setConfirmCancel(true);
5998
+ void openCancelDialog();
5801
5999
  setMenuOpen(false);
5802
6000
  },
5803
6001
  className: "flex w-full items-center gap-2 px-3 py-2 text-xs text-destructive transition-colors hover:bg-destructive/10",
5804
6002
  children: [
5805
6003
  /* @__PURE__ */ jsx(Ban, { className: "h-3.5 w-3.5" }),
5806
- " ",
5807
- t2("financial.invoiceForm.cancelInvoice")
6004
+ " Cancelar"
5808
6005
  ]
5809
6006
  }
5810
6007
  ) })
@@ -6104,7 +6301,7 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
6104
6301
  sideSections,
6105
6302
  children: [
6106
6303
  contactSection,
6107
- /* @__PURE__ */ jsx(
6304
+ /* @__PURE__ */ jsxs(
6108
6305
  OrderFormItemsCard,
6109
6306
  {
6110
6307
  "data-tour": "financial.items",
@@ -6129,112 +6326,129 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
6129
6326
  cancelLabel: t2("financial.invoiceForm.cancel")
6130
6327
  }
6131
6328
  ),
6132
- children: items.map((item, i) => /* @__PURE__ */ jsx(
6133
- OrderFormItemRow,
6134
- {
6135
- index: i,
6136
- itemKind: item.itemKind,
6137
- description: item.description,
6138
- quantity: item.quantity,
6139
- unitPrice: item.unitPrice,
6140
- discount: item.discount,
6141
- surcharge: item.surcharge,
6142
- referenceId: item.referenceId,
6143
- itemTypes,
6144
- currency,
6145
- formatMoney: money4,
6146
- expanded: expandedItemId === item._id,
6147
- readOnly: !canEditItems,
6148
- onToggle: () => setExpandedItemId(expandedItemId === item._id ? null : item._id),
6149
- onChange: (patch) => {
6150
- void applyItemPatch(item, patch);
6151
- },
6152
- onRemove: () => {
6153
- removeItem(item._id);
6154
- if (expandedItemId === item._id) setExpandedItemId(null);
6155
- },
6156
- extraFieldGroups: [
6157
- ...postable.length > 0 ? [{
6158
- id: "account",
6159
- label: t2("financial.invoiceForm.account"),
6160
- filled: !!item.accountId,
6161
- // O plano de contas na linha fechada: classificar deixa de
6162
- // ser trabalho invisível. Ver OrderFormExtraFieldGroup.summary.
6163
- summary: postable.find((a) => a.id === item.accountId)?.name,
6164
- onClear: () => updateItem(item._id, { accountId: void 0 }),
6165
- field: (
6166
- // Busca, não <select>: um plano de contas real tem dezenas
6167
- // ou centenas de folhas, e a lista nativa obriga a rolar
6168
- // atrás de um código que a pessoa já sabe de cor.
6169
- /* @__PURE__ */ jsx(
6329
+ children: [
6330
+ items.map((item, i) => /* @__PURE__ */ jsx(
6331
+ OrderFormItemRow,
6332
+ {
6333
+ index: i,
6334
+ itemKind: item.itemKind,
6335
+ description: item.description,
6336
+ quantity: item.quantity,
6337
+ unitPrice: item.unitPrice,
6338
+ discount: item.discount,
6339
+ surcharge: item.surcharge,
6340
+ referenceId: item.referenceId,
6341
+ itemTypes,
6342
+ currency,
6343
+ formatMoney: money4,
6344
+ expanded: expandedItemId === item._id,
6345
+ readOnly: !canEditItems,
6346
+ onToggle: () => setExpandedItemId(expandedItemId === item._id ? null : item._id),
6347
+ onChange: (patch) => {
6348
+ void applyItemPatch(item, patch);
6349
+ },
6350
+ onRemove: () => {
6351
+ removeItem(item._id);
6352
+ if (expandedItemId === item._id) setExpandedItemId(null);
6353
+ },
6354
+ extraFieldGroups: [
6355
+ ...postable.length > 0 ? [{
6356
+ id: "account",
6357
+ label: t2("financial.invoiceForm.account"),
6358
+ filled: !!item.accountId,
6359
+ // O plano de contas na linha fechada: classificar deixa de
6360
+ // ser trabalho invisível. Ver OrderFormExtraFieldGroup.summary.
6361
+ summary: postable.find((a) => a.id === item.accountId)?.name,
6362
+ onClear: () => updateItem(item._id, { accountId: void 0 }),
6363
+ field: (
6364
+ // Busca, não <select>: um plano de contas real tem dezenas
6365
+ // ou centenas de folhas, e a lista nativa obriga a rolar
6366
+ // atrás de um código que a pessoa já sabe de cor.
6367
+ /* @__PURE__ */ jsx(
6368
+ SearchSelect,
6369
+ {
6370
+ label: t2("financial.invoiceForm.account"),
6371
+ value: item.accountId,
6372
+ displayValue: postable.find((a) => a.id === item.accountId)?.name,
6373
+ placeholder: t2("financial.invoiceForm.accountNone"),
6374
+ minChars: 0,
6375
+ noResultsLabel: t2("financial.invoiceForm.accountNone"),
6376
+ onSearch: (q) => filterByCodeOrName(selectableAccounts, q).map((a) => ({
6377
+ id: a.id,
6378
+ label: accountLabel(a),
6379
+ subtitle: a.accountType ?? void 0
6380
+ })),
6381
+ onChange: (id) => updateItem(item._id, { accountId: id || void 0 }),
6382
+ onManage: canManageFinancial ? () => adminNavigateTo(CHART_OF_ACCOUNTS_ROUTE) : void 0,
6383
+ manageLabel: t2("financial.invoiceForm.manageChart"),
6384
+ clearLabel: t2("financial.invoiceForm.clear")
6385
+ }
6386
+ )
6387
+ )
6388
+ }] : [],
6389
+ ...costCenters.length > 0 ? [{
6390
+ id: "costCenter",
6391
+ label: t2("financial.invoiceForm.costCenter"),
6392
+ filled: !!item.costCenterId,
6393
+ onClear: () => updateItem(item._id, { costCenterId: void 0 }),
6394
+ field: /* @__PURE__ */ jsx(
6170
6395
  SearchSelect,
6171
6396
  {
6172
- label: t2("financial.invoiceForm.account"),
6173
- value: item.accountId,
6174
- displayValue: postable.find((a) => a.id === item.accountId)?.name,
6175
- placeholder: t2("financial.invoiceForm.accountNone"),
6397
+ label: t2("financial.invoiceForm.costCenter"),
6398
+ value: item.costCenterId,
6399
+ displayValue: costCenters.find((c) => c.id === item.costCenterId)?.name,
6400
+ placeholder: t2("financial.invoiceForm.costCenterNone"),
6176
6401
  minChars: 0,
6177
- noResultsLabel: t2("financial.invoiceForm.accountNone"),
6178
- onSearch: (q) => filterByCodeOrName(selectableAccounts, q).map((a) => ({
6179
- id: a.id,
6180
- label: accountLabel(a),
6181
- subtitle: a.accountType ?? void 0
6402
+ noResultsLabel: t2("financial.invoiceForm.costCenterNone"),
6403
+ onSearch: (q) => filterByCodeOrName(costCenters, q).map((c) => ({
6404
+ id: c.id,
6405
+ label: c.code ? `${c.code} - ${c.name}` : c.name
6182
6406
  })),
6183
- onChange: (id) => updateItem(item._id, { accountId: id || void 0 }),
6184
- onManage: canManageFinancial ? () => adminNavigateTo(CHART_OF_ACCOUNTS_ROUTE) : void 0,
6185
- manageLabel: t2("financial.invoiceForm.manageChart"),
6407
+ onChange: (id) => updateItem(item._id, { costCenterId: id || void 0 }),
6408
+ onManage: canManageFinancial ? () => adminNavigateTo(COST_CENTERS_ROUTE) : void 0,
6409
+ manageLabel: t2("financial.invoiceForm.manageCostCenters"),
6186
6410
  clearLabel: t2("financial.invoiceForm.clear")
6187
6411
  }
6188
6412
  )
6189
- )
6190
- }] : [],
6191
- ...costCenters.length > 0 ? [{
6192
- id: "costCenter",
6193
- label: t2("financial.invoiceForm.costCenter"),
6194
- filled: !!item.costCenterId,
6195
- onClear: () => updateItem(item._id, { costCenterId: void 0 }),
6196
- field: /* @__PURE__ */ jsx(
6197
- SearchSelect,
6198
- {
6199
- label: t2("financial.invoiceForm.costCenter"),
6200
- value: item.costCenterId,
6201
- displayValue: costCenters.find((c) => c.id === item.costCenterId)?.name,
6202
- placeholder: t2("financial.invoiceForm.costCenterNone"),
6203
- minChars: 0,
6204
- noResultsLabel: t2("financial.invoiceForm.costCenterNone"),
6205
- onSearch: (q) => filterByCodeOrName(costCenters, q).map((c) => ({
6206
- id: c.id,
6207
- label: c.code ? `${c.code} - ${c.name}` : c.name
6208
- })),
6209
- onChange: (id) => updateItem(item._id, { costCenterId: id || void 0 }),
6210
- onManage: canManageFinancial ? () => adminNavigateTo(COST_CENTERS_ROUTE) : void 0,
6211
- manageLabel: t2("financial.invoiceForm.manageCostCenters"),
6212
- clearLabel: t2("financial.invoiceForm.clear")
6213
- }
6214
- )
6215
- }] : []
6216
- ],
6217
- entityLookups,
6218
- pickOnly: Boolean(entityLookups[item.itemKind]?.expand) && !item.referenceId,
6219
- showTypeSelector: hasItemLookups,
6220
- allowSurcharge: true,
6221
- labels: {
6222
- item: t2("financial.invoiceForm.item"),
6223
- description: t2("financial.invoiceForm.description"),
6224
- descriptionPlaceholder: t2("financial.invoiceForm.itemDescription"),
6225
- noDescription: t2("financial.invoiceForm.noDescription"),
6226
- qty: t2("financial.invoiceForm.qty"),
6227
- unitPrice: t2("financial.invoiceForm.unitPrice"),
6228
- discount: t2("financial.invoiceForm.discount"),
6229
- surcharge: t2("financial.invoiceForm.surcharge"),
6230
- adjustments: t2("financial.invoiceForm.discountSurcharge"),
6231
- removeAdjustments: t2("financial.invoiceForm.removeAdjustments"),
6232
- done: t2("financial.invoiceForm.done"),
6233
- edit: t2("financial.invoiceForm.edit")
6234
- }
6235
- },
6236
- item._id
6237
- ))
6413
+ }] : []
6414
+ ],
6415
+ entityLookups,
6416
+ pickOnly: Boolean(entityLookups[item.itemKind]?.expand) && !item.referenceId,
6417
+ showTypeSelector: hasItemLookups,
6418
+ allowSurcharge: true,
6419
+ labels: {
6420
+ item: t2("financial.invoiceForm.item"),
6421
+ description: t2("financial.invoiceForm.description"),
6422
+ descriptionPlaceholder: t2("financial.invoiceForm.itemDescription"),
6423
+ noDescription: t2("financial.invoiceForm.noDescription"),
6424
+ qty: t2("financial.invoiceForm.qty"),
6425
+ unitPrice: t2("financial.invoiceForm.unitPrice"),
6426
+ discount: t2("financial.invoiceForm.discount"),
6427
+ surcharge: t2("financial.invoiceForm.surcharge"),
6428
+ adjustments: t2("financial.invoiceForm.discountSurcharge"),
6429
+ removeAdjustments: t2("financial.invoiceForm.removeAdjustments"),
6430
+ done: t2("financial.invoiceForm.done"),
6431
+ edit: t2("financial.invoiceForm.edit")
6432
+ }
6433
+ },
6434
+ item._id
6435
+ )),
6436
+ cancelledLines.map((line) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 border-t py-2 pl-4 pr-11 text-muted-foreground", children: [
6437
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
6438
+ /* @__PURE__ */ jsx("p", { className: "truncate text-sm line-through", children: line.description }),
6439
+ /* @__PURE__ */ jsxs("p", { className: "truncate text-xs", children: [
6440
+ "Cancelado",
6441
+ line.cancelReason ? ` \u2014 ${line.cancelReason}` : ""
6442
+ ] })
6443
+ ] }),
6444
+ /* @__PURE__ */ jsxs("span", { className: "shrink-0 text-xs tabular-nums line-through", children: [
6445
+ line.quantity,
6446
+ " \xD7 ",
6447
+ money4(line.unitPrice)
6448
+ ] }),
6449
+ /* @__PURE__ */ jsx("span", { className: "w-24 shrink-0 text-right text-sm tabular-nums line-through", children: money4(line.totalAmount) })
6450
+ ] }, line.id))
6451
+ ]
6238
6452
  }
6239
6453
  ),
6240
6454
  scheduleSection
@@ -6277,37 +6491,19 @@ function InvoiceFormView({ direction, editId, onSaved, focusReceipt = false, fro
6277
6491
  }
6278
6492
  }
6279
6493
  ),
6280
- confirmCancel && createPortal(
6281
- /* @__PURE__ */ jsx("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50", onClick: closeCancelDialog, children: /* @__PURE__ */ jsxs("div", { className: "w-full max-w-sm rounded-modal border bg-card shadow-lg mx-4 p-5", onClick: (e) => e.stopPropagation(), style: { animation: "field-slide-in 200ms ease-out" }, children: [
6282
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 mb-3", children: [
6283
- /* @__PURE__ */ jsx("div", { className: "flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 shrink-0", children: /* @__PURE__ */ jsx(Ban, { className: "h-5 w-5 text-destructive" }) }),
6284
- /* @__PURE__ */ jsxs("div", { children: [
6285
- /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold", children: t2("financial.invoiceForm.cancelInvoice") }),
6286
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("financial.invoiceForm.cancelConfirm") })
6287
- ] })
6288
- ] }),
6289
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mb-4", children: t2("financial.invoiceForm.cancelDesc") }),
6290
- /* @__PURE__ */ jsx("label", { className: "block text-xs font-medium mb-1", htmlFor: "invoice-form-cancel-reason", children: t2("financial.invoice.cancelReason") }),
6291
- /* @__PURE__ */ jsx(
6292
- "textarea",
6293
- {
6294
- id: "invoice-form-cancel-reason",
6295
- value: cancelReason,
6296
- onChange: (event) => setCancelReason(event.target.value),
6297
- placeholder: t2("financial.invoice.cancelReasonPlaceholder"),
6298
- className: "mb-4 min-h-20 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm",
6299
- autoFocus: true
6300
- }
6301
- ),
6302
- /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
6303
- /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: closeCancelDialog, children: t2("financial.invoiceForm.keepInvoice") }),
6304
- /* @__PURE__ */ jsxs(Button, { variant: "destructive", size: "sm", onClick: handleCancelInvoice, disabled: cancelling || !cancelReason.trim(), children: [
6305
- /* @__PURE__ */ jsx(Ban, { className: "h-3.5 w-3.5" }),
6306
- cancelling ? t2("financial.invoiceForm.cancelling") : t2("financial.invoiceForm.yesCancel")
6307
- ] })
6308
- ] })
6309
- ] }) }),
6310
- document.body
6494
+ cancelData && editId && /* @__PURE__ */ jsx(
6495
+ CancelInvoiceDialog,
6496
+ {
6497
+ invoiceId: editId,
6498
+ counterpartyName: cancelData.name,
6499
+ totalAmount: cancelData.total,
6500
+ items: cancelData.items,
6501
+ onClose: () => setCancelData(null),
6502
+ onCancelled: () => {
6503
+ setCancelData(null);
6504
+ onSaved?.();
6505
+ }
6506
+ }
6311
6507
  ),
6312
6508
  /* @__PURE__ */ jsx("style", { children: `
6313
6509
  @keyframes field-slide-in {
@@ -6507,17 +6703,8 @@ function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel, em
6507
6703
  const [attachmentsOpen, setAttachmentsOpen] = useState(false);
6508
6704
  const [pendingReceipt, setPendingReceipt] = useState();
6509
6705
  const [menuOpen, setMenuOpen] = useState(false);
6510
- const [confirmCancel, setConfirmCancel] = useState(false);
6511
- const [cancelling, setCancelling] = useState(false);
6512
- const [cancelReason, setCancelReason] = useState("");
6513
- const cancelRequestKey = useRef(createIdempotencyKey("financial:invoice-cancel"));
6706
+ const [cancelTarget, setCancelTarget] = useState(null);
6514
6707
  const menuRef = useRef(null);
6515
- const cancelInvoice = useFinancialStore((s) => s.cancelInvoice);
6516
- function closeCancelDialog() {
6517
- setConfirmCancel(false);
6518
- setCancelReason("");
6519
- cancelRequestKey.current = createIdempotencyKey("financial:invoice-cancel");
6520
- }
6521
6708
  async function patchSource(mov, next) {
6522
6709
  const metadata = { ...mov.metadata ?? {}, ...next };
6523
6710
  setMovements((prev) => prev.map((m) => m.id === mov.id ? { ...m, metadata } : m));
@@ -6621,24 +6808,8 @@ function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel, em
6621
6808
  useEffect(() => {
6622
6809
  void loadPaymentDetails();
6623
6810
  }, [invoiceId]);
6624
- async function handleCancel() {
6625
- setCancelling(true);
6626
- try {
6627
- await cancelInvoice({
6628
- invoiceId,
6629
- reason: cancelReason,
6630
- idempotencyKey: cancelRequestKey.current
6631
- });
6632
- await loadData();
6633
- closeCancelDialog();
6634
- setMenuOpen(false);
6635
- } catch {
6636
- } finally {
6637
- setCancelling(false);
6638
- }
6639
- }
6640
- const [chartNames, setChartNames] = React25__default.useState(/* @__PURE__ */ new Map());
6641
- React25__default.useEffect(() => {
6811
+ const [chartNames, setChartNames] = React26__default.useState(/* @__PURE__ */ new Map());
6812
+ React26__default.useEffect(() => {
6642
6813
  let cancelled2 = false;
6643
6814
  void provider.getChartOfAccounts().then((rows) => {
6644
6815
  if (!cancelled2) setChartNames(new Map(rows.map((r) => [r.id, r.name])));
@@ -6675,9 +6846,11 @@ function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel, em
6675
6846
  statusConfig.icon;
6676
6847
  const totalPaid = movements.reduce((sum, m) => sum + m.paidAmount, 0);
6677
6848
  const totalRemaining = invoice.totalAmount - totalPaid;
6678
- const subtotal = items.reduce((sum, it) => sum + it.quantity * it.unitPrice, 0);
6679
- const totalDiscount = items.reduce((sum, it) => sum + it.discount, 0);
6680
- const totalSurcharge = items.reduce((sum, it) => sum + it.surcharge, 0);
6849
+ const counted = items.filter((it) => !it.cancelledAt);
6850
+ const subtotal = counted.reduce((sum, it) => sum + it.quantity * it.unitPrice, 0);
6851
+ const totalDiscount = counted.reduce((sum, it) => sum + it.discount, 0);
6852
+ const totalSurcharge = counted.reduce((sum, it) => sum + it.surcharge, 0);
6853
+ const liveItems = items.filter((it) => !it.cancelledAt);
6681
6854
  const detailItems = items.map((it, i) => ({
6682
6855
  id: it.id ?? String(i),
6683
6856
  // Uma linha sem texto próprio herda o da fatura — melhor repetir o assunto
@@ -6686,7 +6859,8 @@ function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel, em
6686
6859
  // `itemKind` é vocabulário do banco, não do balcão: 'other' não diz nada a
6687
6860
  // ninguém e era o que sobrava quando a linha vinha sem nome. Só aparece
6688
6861
  // quando tem uma tradução de verdade.
6689
- description: itemKindLabel(it.itemKind, t2),
6862
+ description: it.cancelledAt ? `Cancelado${it.cancelReason ? ` \u2014 ${it.cancelReason}` : ""}` : itemKindLabel(it.itemKind, t2),
6863
+ struck: !!it.cancelledAt,
6690
6864
  // A classificação vai no campo dela, que o arquétipo desenha como pastilha
6691
6865
  // no meio da linha — o mesmo lugar que ela ocupa no formulário.
6692
6866
  account: it.accountId ? chartNames.get(it.accountId) : void 0,
@@ -6695,7 +6869,10 @@ function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel, em
6695
6869
  // a coluna de valores não dança) e leva para a edição com ESTA linha
6696
6870
  // aberta — sem ele, conferir um item e querer corrigi-lo custava abrir a
6697
6871
  // edição e reencontrar ali o que o dedo já estava apontando.
6698
- ...onEdit && invoice.status !== "cancelled" && movements.every((m) => m.paidAmount <= 0) ? { actions: [{ id: "edit", label: t2("financial.invoiceForm.edit"), icon: Pencil, onSelect: () => onEdit(i) }] } : {},
6872
+ ...invoice.status !== "cancelled" && !it.cancelledAt ? { actions: [
6873
+ ...onEdit && movements.every((m) => m.paidAmount <= 0) ? [{ id: "edit", label: t2("financial.invoiceForm.edit"), icon: Pencil, onSelect: () => onEdit(i) }] : [],
6874
+ { id: "cancel", label: "Cancelar", icon: Ban, onSelect: () => setCancelTarget(it.id) }
6875
+ ] } : {},
6699
6876
  quantity: it.quantity,
6700
6877
  unitPrice: formatCurrency(it.unitPrice, currency),
6701
6878
  total: formatCurrency(it.totalAmount, currency)
@@ -6715,9 +6892,10 @@ function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel, em
6715
6892
  /* @__PURE__ */ jsx(
6716
6893
  PageActions,
6717
6894
  {
6718
- actions: !cancelled && invoice.status !== "paid" ? [
6719
- ...onEdit ? [{ id: "edit", label: t2("financial.invoice.edit"), icon: Pencil, intent: "primary", onSelect: () => onEdit() }] : [],
6720
- { id: "cancel", label: t2("financial.invoice.cancelInvoice"), icon: Ban, intent: "danger", onSelect: () => setConfirmCancel(true) }
6895
+ actions: !cancelled ? [
6896
+ ...onEdit && invoice.status !== "paid" ? [{ id: "edit", label: t2("financial.invoice.edit"), icon: Pencil, intent: "primary", onSelect: () => onEdit() }] : [],
6897
+ // Also on a paid bill: what was paid becomes the counterparty's credit (V1).
6898
+ { id: "cancel", label: "Cancelar", icon: Ban, intent: "danger", onSelect: () => setCancelTarget("") }
6721
6899
  ] : []
6722
6900
  }
6723
6901
  )
@@ -6753,7 +6931,7 @@ function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel, em
6753
6931
  actions,
6754
6932
  stats: [
6755
6933
  { id: "total", label: t2("financial.invoice.total"), value: formatCurrency(invoice.totalAmount, currency), icon: DollarSign },
6756
- { id: "items", label: t2("financial.invoice.items"), value: items.length, icon: LayoutList },
6934
+ { id: "items", label: t2("financial.invoice.items"), value: liveItems.length, icon: LayoutList },
6757
6935
  { id: "installments", label: t2("financial.invoice.installments"), value: `${invoice.totalInstallments}x`, icon: Layers },
6758
6936
  {
6759
6937
  id: "date",
@@ -6990,38 +7168,20 @@ function InvoiceDetailView({ invoiceId, direction, onBack, onEdit, backLabel, em
6990
7168
  }
6991
7169
  }
6992
7170
  ),
6993
- confirmCancel && /* @__PURE__ */ jsxs(
6994
- ModalTask,
7171
+ cancelTarget !== null && /* @__PURE__ */ jsx(
7172
+ CancelInvoiceDialog,
6995
7173
  {
6996
- open: true,
6997
- title: t2("financial.invoice.cancelInvoice"),
6998
- hint: t2("financial.invoice.cancelConfirm"),
6999
- width: "max-w-sm",
7000
- onClose: closeCancelDialog,
7001
- children: [
7002
- /* @__PURE__ */ jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto px-5 py-4", children: [
7003
- /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mb-4", children: t2("financial.invoice.cancelDesc") }),
7004
- /* @__PURE__ */ jsx("label", { className: "block text-xs font-medium mb-1", htmlFor: "invoice-cancel-reason", children: t2("financial.invoice.cancelReason") }),
7005
- /* @__PURE__ */ jsx(
7006
- "textarea",
7007
- {
7008
- id: "invoice-cancel-reason",
7009
- value: cancelReason,
7010
- onChange: (event) => setCancelReason(event.target.value),
7011
- placeholder: t2("financial.invoice.cancelReasonPlaceholder"),
7012
- className: "min-h-20 w-full resize-y rounded-md border bg-background px-3 py-2 text-sm",
7013
- autoFocus: true
7014
- }
7015
- )
7016
- ] }),
7017
- /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 justify-end gap-2 border-t px-5 py-3", children: [
7018
- /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: closeCancelDialog, children: t2("financial.invoice.keepInvoice") }),
7019
- /* @__PURE__ */ jsxs(Button, { variant: "destructive", size: "sm", onClick: handleCancel, disabled: cancelling || !cancelReason.trim(), children: [
7020
- /* @__PURE__ */ jsx(Ban, { className: "h-3.5 w-3.5" }),
7021
- cancelling ? t2("financial.invoice.cancelling") : t2("financial.invoice.yesCancel")
7022
- ] })
7023
- ] })
7024
- ]
7174
+ invoiceId: invoice.id,
7175
+ counterpartyName: invoice.contactName,
7176
+ totalAmount: invoice.totalAmount,
7177
+ items: liveItems,
7178
+ preselectItemId: cancelTarget || void 0,
7179
+ onClose: () => setCancelTarget(null),
7180
+ onCancelled: () => {
7181
+ setCancelTarget(null);
7182
+ setMenuOpen(false);
7183
+ void loadData();
7184
+ }
7025
7185
  }
7026
7186
  ),
7027
7187
  /* @__PURE__ */ jsx("style", { children: `
@@ -7635,7 +7795,7 @@ function CashRegistersView({ variant = "page" } = {}) {
7635
7795
  const [tab, setTab] = useState("open");
7636
7796
  const [reviewRows, setReviewRows] = useState(null);
7637
7797
  const [reviewError, setReviewError] = useState(null);
7638
- const loadReviews = React25__default.useCallback(async () => {
7798
+ const loadReviews = React26__default.useCallback(async () => {
7639
7799
  try {
7640
7800
  setReviewError(null);
7641
7801
  setReviewRows(await provider.getCashSessionReviews?.() ?? []);
@@ -8235,7 +8395,7 @@ function StatementsView({ onNavigate }) {
8235
8395
  "extrato"
8236
8396
  );
8237
8397
  };
8238
- const headerActions = React25__default.useMemo(() => [{
8398
+ const headerActions = React26__default.useMemo(() => [{
8239
8399
  id: "new-transfer",
8240
8400
  label: t2("financial.statements.newTransfer"),
8241
8401
  icon: ArrowRightLeft,
@@ -8431,15 +8591,15 @@ function CommissionsView() {
8431
8591
  const fetchCommissions = useFinancialStore((s) => s.fetchCommissions);
8432
8592
  const generateCommissions = useFinancialStore((s) => s.generateCommissions);
8433
8593
  const provider = useFinancialProvider();
8434
- const [from, setFrom] = React25__default.useState(firstOfMonth());
8435
- const [to, setTo] = React25__default.useState(today2());
8436
- const [preset, setPreset] = React25__default.useState("this_month");
8437
- const [running, setRunning] = React25__default.useState(false);
8438
- const [lastRun, setLastRun] = React25__default.useState(null);
8439
- const [generation, setGeneration] = React25__default.useState(null);
8440
- const [busy, setBusy] = React25__default.useState(null);
8441
- const [error, setError] = React25__default.useState(null);
8442
- const loadGeneration = React25__default.useCallback(async () => {
8594
+ const [from, setFrom] = React26__default.useState(firstOfMonth());
8595
+ const [to, setTo] = React26__default.useState(today2());
8596
+ const [preset, setPreset] = React26__default.useState("this_month");
8597
+ const [running, setRunning] = React26__default.useState(false);
8598
+ const [lastRun, setLastRun] = React26__default.useState(null);
8599
+ const [generation, setGeneration] = React26__default.useState(null);
8600
+ const [busy, setBusy] = React26__default.useState(null);
8601
+ const [error, setError] = React26__default.useState(null);
8602
+ const loadGeneration = React26__default.useCallback(async () => {
8443
8603
  try {
8444
8604
  const gens = await provider.getCommissionGenerations?.() ?? [];
8445
8605
  const doPeriodo = gens.filter((g) => g.periodStart === from && g.periodEnd === to);
@@ -8447,11 +8607,11 @@ function CommissionsView() {
8447
8607
  } catch {
8448
8608
  }
8449
8609
  }, [provider, from, to]);
8450
- React25__default.useEffect(() => {
8610
+ React26__default.useEffect(() => {
8451
8611
  void fetchCommissions({ periodStart: from, periodEnd: to });
8452
8612
  void loadGeneration();
8453
8613
  }, [fetchCommissions, loadGeneration, from, to]);
8454
- const byPerson = React25__default.useMemo(() => {
8614
+ const byPerson = React26__default.useMemo(() => {
8455
8615
  const map = /* @__PURE__ */ new Map();
8456
8616
  for (const c of commissions2) {
8457
8617
  const key = c.personId;
@@ -8504,7 +8664,7 @@ function CommissionsView() {
8504
8664
  setBusy(null);
8505
8665
  }
8506
8666
  }
8507
- const headerActions = React25__default.useMemo(() => [{
8667
+ const headerActions = React26__default.useMemo(() => [{
8508
8668
  id: "commission-rules",
8509
8669
  label: "Configurar regras de comiss\xE3o",
8510
8670
  icon: Settings,
@@ -8952,13 +9112,13 @@ function money3(v) {
8952
9112
  function GoalsView() {
8953
9113
  const { labels } = useFinancialConfig();
8954
9114
  const provider = useFinancialProvider();
8955
- const [units, setUnits] = React25__default.useState([]);
8956
- const [unitId, setUnitId] = React25__default.useState("");
8957
- const [year, setYear] = React25__default.useState((/* @__PURE__ */ new Date()).getFullYear());
8958
- const [rows, setRows] = React25__default.useState(null);
8959
- const [editing, setEditing] = React25__default.useState(null);
8960
- const [error, setError] = React25__default.useState(null);
8961
- React25__default.useEffect(() => {
9115
+ const [units, setUnits] = React26__default.useState([]);
9116
+ const [unitId, setUnitId] = React26__default.useState("");
9117
+ const [year, setYear] = React26__default.useState((/* @__PURE__ */ new Date()).getFullYear());
9118
+ const [rows, setRows] = React26__default.useState(null);
9119
+ const [editing, setEditing] = React26__default.useState(null);
9120
+ const [error, setError] = React26__default.useState(null);
9121
+ React26__default.useEffect(() => {
8962
9122
  void (async () => {
8963
9123
  const cat = await provider.getCommissionCatalogs?.();
8964
9124
  const us = cat?.units ?? [];
@@ -8966,7 +9126,7 @@ function GoalsView() {
8966
9126
  setUnitId((cur) => cur || us[0]?.id || "");
8967
9127
  })();
8968
9128
  }, [provider]);
8969
- const load = React25__default.useCallback(async () => {
9129
+ const load = React26__default.useCallback(async () => {
8970
9130
  if (!unitId) return;
8971
9131
  setRows(null);
8972
9132
  try {
@@ -8977,7 +9137,7 @@ function GoalsView() {
8977
9137
  setRows([]);
8978
9138
  }
8979
9139
  }, [provider, unitId, year]);
8980
- React25__default.useEffect(() => {
9140
+ React26__default.useEffect(() => {
8981
9141
  void load();
8982
9142
  }, [load]);
8983
9143
  const totalRealizado = (rows ?? []).reduce((s, r) => s + r.achievedAmount, 0);
@@ -9108,11 +9268,11 @@ function GoalDialog({
9108
9268
  onSaved
9109
9269
  }) {
9110
9270
  const provider = useFinancialProvider();
9111
- const [target, setTarget] = React25__default.useState(String(goal.targetAmount || ""));
9112
- const [sup, setSup] = React25__default.useState(String(goal.superAmount || ""));
9113
- const [hyper, setHyper] = React25__default.useState(String(goal.hyperAmount || ""));
9114
- const [saving, setSaving] = React25__default.useState(false);
9115
- const [error, setError] = React25__default.useState(null);
9271
+ const [target, setTarget] = React26__default.useState(String(goal.targetAmount || ""));
9272
+ const [sup, setSup] = React26__default.useState(String(goal.superAmount || ""));
9273
+ const [hyper, setHyper] = React26__default.useState(String(goal.hyperAmount || ""));
9274
+ const [saving, setSaving] = React26__default.useState(false);
9275
+ const [error, setError] = React26__default.useState(null);
9116
9276
  const t2 = Number(target) || 0;
9117
9277
  const s = Number(sup) || 0;
9118
9278
  const h = Number(hyper) || 0;
@@ -9160,7 +9320,7 @@ function GoalDialog({
9160
9320
  ladderErr && /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: ladderErr }),
9161
9321
  error && /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: error })
9162
9322
  ] }) }),
9163
- /* @__PURE__ */ jsxs(ModalFooter, { className: "border-t px-5 py-3", children: [
9323
+ /* @__PURE__ */ jsxs(ModalFooter, { variant: "bar", children: [
9164
9324
  /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: onClose, children: "Cancelar" }),
9165
9325
  /* @__PURE__ */ jsxs(Button, { size: "sm", onClick: () => void save(), disabled: saving || !!ladderErr, children: [
9166
9326
  saving ? /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }) : null,
@@ -9203,21 +9363,21 @@ function GiftCardsView() {
9203
9363
  const provider = useFinancialProvider();
9204
9364
  const { currency } = useFinancialConfig();
9205
9365
  const can = usePermissionOptional();
9206
- const [enabled, setEnabled] = React25__default.useState(null);
9207
- const [cards, setCards] = React25__default.useState(null);
9208
- const [error, setError] = React25__default.useState(null);
9209
- const [issuing, setIssuing] = React25__default.useState(false);
9210
- const [issued, setIssued] = React25__default.useState(null);
9211
- const [activating, setActivating] = React25__default.useState(null);
9212
- const [search, setSearch] = React25__default.useState("");
9213
- const [state, setState] = React25__default.useState("");
9214
- const [minValue, setMinValue] = React25__default.useState("");
9215
- const [maxValue, setMaxValue] = React25__default.useState("");
9216
- const [from, setFrom] = React25__default.useState("");
9217
- const [to, setTo] = React25__default.useState("");
9218
- const money4 = React25__default.useCallback((v) => formatCurrency(v, currency), [currency]);
9219
- const seq = React25__default.useRef(0);
9220
- const load = React25__default.useCallback(async () => {
9366
+ const [enabled, setEnabled] = React26__default.useState(null);
9367
+ const [cards, setCards] = React26__default.useState(null);
9368
+ const [error, setError] = React26__default.useState(null);
9369
+ const [issuing, setIssuing] = React26__default.useState(false);
9370
+ const [issued, setIssued] = React26__default.useState(null);
9371
+ const [activating, setActivating] = React26__default.useState(null);
9372
+ const [search, setSearch] = React26__default.useState("");
9373
+ const [state, setState] = React26__default.useState("");
9374
+ const [minValue, setMinValue] = React26__default.useState("");
9375
+ const [maxValue, setMaxValue] = React26__default.useState("");
9376
+ const [from, setFrom] = React26__default.useState("");
9377
+ const [to, setTo] = React26__default.useState("");
9378
+ const money4 = React26__default.useCallback((v) => formatCurrency(v, currency), [currency]);
9379
+ const seq = React26__default.useRef(0);
9380
+ const load = React26__default.useCallback(async () => {
9221
9381
  const mine = ++seq.current;
9222
9382
  setCards(null);
9223
9383
  setError(null);
@@ -9238,7 +9398,7 @@ function GiftCardsView() {
9238
9398
  setCards([]);
9239
9399
  }
9240
9400
  }, [provider, search, state, minValue, maxValue, from, to]);
9241
- React25__default.useEffect(() => {
9401
+ React26__default.useEffect(() => {
9242
9402
  void (async () => {
9243
9403
  try {
9244
9404
  setEnabled(await provider.giftCardsEnabled?.() === true);
@@ -9247,10 +9407,10 @@ function GiftCardsView() {
9247
9407
  }
9248
9408
  })();
9249
9409
  }, [provider]);
9250
- React25__default.useEffect(() => {
9410
+ React26__default.useEffect(() => {
9251
9411
  if (enabled) void load();
9252
9412
  }, [enabled, load]);
9253
- const columns = React25__default.useMemo(() => [
9413
+ const columns = React26__default.useMemo(() => [
9254
9414
  {
9255
9415
  accessorKey: "code",
9256
9416
  header: "C\xF3digo",
@@ -9431,12 +9591,12 @@ function IssueDialog({
9431
9591
  onIssued
9432
9592
  }) {
9433
9593
  const provider = useFinancialProvider();
9434
- const [quantity, setQuantity] = React25__default.useState("10");
9435
- const [amount, setAmount] = React25__default.useState("100");
9436
- const [expiresAt, setExpiresAt] = React25__default.useState("");
9437
- const [note, setNote] = React25__default.useState("");
9438
- const [saving, setSaving] = React25__default.useState(false);
9439
- const [error, setError] = React25__default.useState(null);
9594
+ const [quantity, setQuantity] = React26__default.useState("10");
9595
+ const [amount, setAmount] = React26__default.useState("100");
9596
+ const [expiresAt, setExpiresAt] = React26__default.useState("");
9597
+ const [note, setNote] = React26__default.useState("");
9598
+ const [saving, setSaving] = React26__default.useState(false);
9599
+ const [error, setError] = React26__default.useState(null);
9440
9600
  const q = Math.trunc(Number(quantity)) || 0;
9441
9601
  const a = Number(amount) || 0;
9442
9602
  const total = q * a;
@@ -9534,8 +9694,8 @@ function IssueDialog({
9534
9694
  ] }) });
9535
9695
  }
9536
9696
  var shortDate = (value) => {
9537
- const [year, month, day] = value.split("-");
9538
- return year && month && day ? `${day}/${month}/${year}` : value;
9697
+ const [year, month, day2] = value.split("-");
9698
+ return year && month && day2 ? `${day2}/${month}/${year}` : value;
9539
9699
  };
9540
9700
  function LinhaDoExtrato({ line, fmt }) {
9541
9701
  const isCredit = line.direction === "credit";
@@ -9737,12 +9897,30 @@ async function runReconcileLink(provider, line, target, options = {}) {
9737
9897
  await provider.reconcileMovement({ bankMovementId: line.id, matchedMovementId: target.movementId });
9738
9898
  return;
9739
9899
  }
9900
+ const adjustments = (options.adjustments ?? []).filter((adj) => round22(adj.amount) > 0);
9901
+ if (provider.addInstallmentCharge && adjustments.length) {
9902
+ let due = target.openAmount;
9903
+ for (const adj of adjustments) {
9904
+ await provider.addInstallmentCharge({
9905
+ installmentId: target.movementId,
9906
+ kind: adj.type,
9907
+ amount: round22(adj.amount),
9908
+ accountId: adj.accountId,
9909
+ description: adjustmentDescription(adj)
9910
+ });
9911
+ due += adj.type === "discount" ? -round22(adj.amount) : round22(adj.amount);
9912
+ }
9913
+ const quitada2 = await provider.payMovement(buildPayment(line, target.movementId, round22(due), options.payment));
9914
+ const paymentId2 = await paymentIdOf(provider, quitada2);
9915
+ await provider.reconcileMovement({ bankMovementId: line.id, matchedMovementId: paymentId2 });
9916
+ return;
9917
+ }
9740
9918
  const quitada = await provider.payMovement(
9741
9919
  buildPayment(line, target.movementId, target.openAmount, options.payment)
9742
9920
  );
9743
9921
  const paymentId = await paymentIdOf(provider, quitada);
9744
9922
  await provider.reconcileMovement({ bankMovementId: line.id, matchedMovementId: paymentId });
9745
- for (const adj of options.adjustments ?? []) {
9923
+ for (const adj of adjustments) {
9746
9924
  if (round22(adj.amount) <= 0) continue;
9747
9925
  const direction = adjustmentDirection(line.direction, adj.type);
9748
9926
  const description = adjustmentDescription(adj);
@@ -9842,8 +10020,8 @@ function createAgentReceiptReader(deps) {
9842
10020
  };
9843
10021
  }
9844
10022
  var shortDate2 = (value) => {
9845
- const [year, month, day] = value.split("-");
9846
- return year && month && day ? `${day}/${month}/${year}` : value;
10023
+ const [year, month, day2] = value.split("-");
10024
+ return year && month && day2 ? `${day2}/${month}/${year}` : value;
9847
10025
  };
9848
10026
  var shiftDate = (value, days) => {
9849
10027
  const d = /* @__PURE__ */ new Date(`${value}T00:00:00`);
@@ -10804,8 +10982,8 @@ function ReconcileFlow({ line, initialIntent, initialTarget, initialStep = 0, on
10804
10982
  }
10805
10983
  var PAGE_SIZE = 50;
10806
10984
  var shortDate3 = (value) => {
10807
- const [year, month, day] = value.split("-");
10808
- return year && month && day ? `${day}/${month}/${year}` : value;
10985
+ const [year, month, day2] = value.split("-");
10986
+ return year && month && day2 ? `${day2}/${month}/${year}` : value;
10809
10987
  };
10810
10988
  var isoDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
10811
10989
  var ACCOUNT_GROUPS2 = {
@@ -10974,11 +11152,11 @@ function ReconciliationView() {
10974
11152
  setBusy(false);
10975
11153
  }
10976
11154
  }
10977
- const RefreshIcon = React25__default.useMemo(
11155
+ const RefreshIcon = React26__default.useMemo(
10978
11156
  () => ({ className }) => /* @__PURE__ */ jsx(RefreshCw, { className: `${className ?? ""} ${loading ? "animate-spin" : ""}` }),
10979
11157
  [loading]
10980
11158
  );
10981
- const headerActions = React25__default.useMemo(() => [{
11159
+ const headerActions = React26__default.useMemo(() => [{
10982
11160
  id: "refresh",
10983
11161
  label: t2("financial.reconciliation.refresh"),
10984
11162
  icon: RefreshIcon,
@@ -11384,8 +11562,8 @@ function today3() {
11384
11562
  }
11385
11563
  function dateLabel(value) {
11386
11564
  if (!value) return "Sem t\xE9rmino";
11387
- const [year, month, day] = value.split("-");
11388
- return year && month && day ? `${day}/${month}/${year}` : value;
11565
+ const [year, month, day2] = value.split("-");
11566
+ return year && month && day2 ? `${day2}/${month}/${year}` : value;
11389
11567
  }
11390
11568
  function newRecurringItem() {
11391
11569
  return { _id: crypto.randomUUID(), itemKind: "other", description: "", quantity: 1, unitPrice: 0 };
@@ -11412,13 +11590,13 @@ function RecurringBillsList({ direction, onNavigate }) {
11412
11590
  const provider = useFinancialProvider();
11413
11591
  const { currency } = useFinancialConfig();
11414
11592
  const can = usePermissionOptional();
11415
- const [rows, setRows] = React25__default.useState(null);
11416
- const [search, setSearch] = React25__default.useState("");
11417
- const [status, setStatus] = React25__default.useState("all");
11418
- const [changing, setChanging] = React25__default.useState(null);
11593
+ const [rows, setRows] = React26__default.useState(null);
11594
+ const [search, setSearch] = React26__default.useState("");
11595
+ const [status, setStatus] = React26__default.useState("all");
11596
+ const [changing, setChanging] = React26__default.useState(null);
11419
11597
  const isPayable = direction === "payable";
11420
11598
  const prefix = isPayable ? "payables" : "receivables";
11421
- const load = React25__default.useCallback(async () => {
11599
+ const load = React26__default.useCallback(async () => {
11422
11600
  setRows(null);
11423
11601
  try {
11424
11602
  setRows(await provider.getRecurringBills?.(direction) ?? []);
@@ -11427,10 +11605,10 @@ function RecurringBillsList({ direction, onNavigate }) {
11427
11605
  toast.error(error?.message ?? "N\xE3o foi poss\xEDvel carregar as recorr\xEAncias.");
11428
11606
  }
11429
11607
  }, [provider, direction]);
11430
- React25__default.useEffect(() => {
11608
+ React26__default.useEffect(() => {
11431
11609
  void load();
11432
11610
  }, [load]);
11433
- const filtered = React25__default.useMemo(() => {
11611
+ const filtered = React26__default.useMemo(() => {
11434
11612
  const term = search.trim().toLowerCase();
11435
11613
  const visiveis = (rows ?? []).filter((row) => {
11436
11614
  if (status === "active" && !row.isActive) return false;
@@ -11440,7 +11618,7 @@ function RecurringBillsList({ direction, onNavigate }) {
11440
11618
  });
11441
11619
  return [...visiveis].sort((a, b) => Number(b.isActive) - Number(a.isActive) || (a.dueDay ?? 0) - (b.dueDay ?? 0) || a.description.localeCompare(b.description));
11442
11620
  }, [rows, search, status]);
11443
- const porMes = React25__default.useMemo(() => {
11621
+ const porMes = React26__default.useMemo(() => {
11444
11622
  const fator = { weekly: 52 / 12, monthly: 1, quarterly: 1 / 3, yearly: 1 / 12 };
11445
11623
  const ativas = filtered.filter((row) => row.isActive);
11446
11624
  return {
@@ -11461,7 +11639,7 @@ function RecurringBillsList({ direction, onNavigate }) {
11461
11639
  setChanging(null);
11462
11640
  }
11463
11641
  }
11464
- const columns = React25__default.useMemo(() => [
11642
+ const columns = React26__default.useMemo(() => [
11465
11643
  {
11466
11644
  accessorKey: "description",
11467
11645
  header: "Descri\xE7\xE3o",
@@ -11559,41 +11737,41 @@ function RecurringBillFormView({ direction, valueId, onBack }) {
11559
11737
  const { locations, currency, itemTypes: configuredItemTypes, entityLookups } = useFinancialConfig();
11560
11738
  const t2 = useTranslation();
11561
11739
  const isPayable = direction === "payable";
11562
- const [value, setValue] = React25__default.useState(null);
11563
- const [loaded, setLoaded] = React25__default.useState(!valueId);
11564
- const [contact, setContact] = React25__default.useState(null);
11565
- const [counterpartyAccounting, setCounterpartyAccounting] = React25__default.useState({
11740
+ const [value, setValue] = React26__default.useState(null);
11741
+ const [loaded, setLoaded] = React26__default.useState(!valueId);
11742
+ const [contact, setContact] = React26__default.useState(null);
11743
+ const [counterpartyAccounting, setCounterpartyAccounting] = React26__default.useState({
11566
11744
  allowedAccountIds: [],
11567
11745
  limitAccounts: false,
11568
11746
  addItemsAutomatically: false
11569
11747
  });
11570
- const accountingPolicyRequest = React25__default.useRef(0);
11571
- const counterpartyLookup = React25__default.useMemo(
11748
+ const accountingPolicyRequest = React26__default.useRef(0);
11749
+ const counterpartyLookup = React26__default.useMemo(
11572
11750
  () => createFinancialCounterpartyLookup(isPayable ? "debit" : "credit"),
11573
11751
  [isPayable]
11574
11752
  );
11575
11753
  const contactCreate = useLookupCreate(counterpartyLookup, (created) => {
11576
11754
  setContact({ id: created.id, name: created.label });
11577
11755
  });
11578
- const initialItem = React25__default.useRef(newRecurringItem());
11579
- const [items, setItems] = React25__default.useState([initialItem.current]);
11580
- const [expandedItemId, setExpandedItemId] = React25__default.useState(initialItem.current._id);
11581
- const [addingItem, setAddingItem] = React25__default.useState(false);
11582
- const [frequency, setFrequency] = React25__default.useState("monthly");
11583
- const [dueDay, setDueDay] = React25__default.useState("1");
11584
- const [startsOn, setStartsOn] = React25__default.useState(today3());
11585
- const [endsOn, setEndsOn] = React25__default.useState("");
11586
- const [unitId, setUnitId] = React25__default.useState(locations[0]?.id ?? "");
11587
- const [availableLocations, setAvailableLocations] = React25__default.useState(locations);
11588
- const [notes, setNotes] = React25__default.useState("");
11589
- const [isActive, setIsActive] = React25__default.useState(true);
11590
- const [accounts, setAccounts] = React25__default.useState([]);
11591
- const [costCenters, setCostCenters] = React25__default.useState([]);
11756
+ const initialItem = React26__default.useRef(newRecurringItem());
11757
+ const [items, setItems] = React26__default.useState([initialItem.current]);
11758
+ const [expandedItemId, setExpandedItemId] = React26__default.useState(initialItem.current._id);
11759
+ const [addingItem, setAddingItem] = React26__default.useState(false);
11760
+ const [frequency, setFrequency] = React26__default.useState("monthly");
11761
+ const [dueDay, setDueDay] = React26__default.useState("1");
11762
+ const [startsOn, setStartsOn] = React26__default.useState(today3());
11763
+ const [endsOn, setEndsOn] = React26__default.useState("");
11764
+ const [unitId, setUnitId] = React26__default.useState(locations[0]?.id ?? "");
11765
+ const [availableLocations, setAvailableLocations] = React26__default.useState(locations);
11766
+ const [notes, setNotes] = React26__default.useState("");
11767
+ const [isActive, setIsActive] = React26__default.useState(true);
11768
+ const [accounts, setAccounts] = React26__default.useState([]);
11769
+ const [costCenters, setCostCenters] = React26__default.useState([]);
11592
11770
  const { choices: unitChoices } = useWritableUnits(availableLocations);
11593
- const [saving, setSaving] = React25__default.useState(false);
11594
- const [ending, setEnding] = React25__default.useState(false);
11595
- const [error, setError] = React25__default.useState(null);
11596
- React25__default.useEffect(() => {
11771
+ const [saving, setSaving] = React26__default.useState(false);
11772
+ const [ending, setEnding] = React26__default.useState(false);
11773
+ const [error, setError] = React26__default.useState(null);
11774
+ React26__default.useEffect(() => {
11597
11775
  if (locations.length > 0) {
11598
11776
  setAvailableLocations(locations);
11599
11777
  return;
@@ -11608,7 +11786,7 @@ function RecurringBillFormView({ direction, valueId, onBack }) {
11608
11786
  cancelled = true;
11609
11787
  };
11610
11788
  }, [locations]);
11611
- React25__default.useEffect(() => {
11789
+ React26__default.useEffect(() => {
11612
11790
  let cancelled = false;
11613
11791
  void Promise.all([
11614
11792
  provider.getChartOfAccounts(),
@@ -11655,25 +11833,25 @@ function RecurringBillFormView({ direction, valueId, onBack }) {
11655
11833
  cancelled = true;
11656
11834
  };
11657
11835
  }, [provider, direction, valueId]);
11658
- const translatedItemTypes = React25__default.useMemo(() => configuredItemTypes.map((tp) => {
11836
+ const translatedItemTypes = React26__default.useMemo(() => configuredItemTypes.map((tp) => {
11659
11837
  const label = t2(`financial.itemType.${tp.value}`);
11660
11838
  return label === `financial.itemType.${tp.value}` ? tp : { ...tp, label };
11661
11839
  }), [configuredItemTypes, t2]);
11662
- const offeredItemTypes = React25__default.useMemo(() => availableInvoiceItemTypes(
11840
+ const offeredItemTypes = React26__default.useMemo(() => availableInvoiceItemTypes(
11663
11841
  translatedItemTypes,
11664
11842
  Object.keys(entityLookups),
11665
11843
  t2("financial.itemType.other"),
11666
11844
  direction
11667
11845
  ), [translatedItemTypes, entityLookups, direction, t2]);
11668
11846
  const itemTypes = useStockedItemTypes(offeredItemTypes, entityLookups);
11669
- const selectableAccounts = React25__default.useMemo(() => {
11847
+ const selectableAccounts = React26__default.useMemo(() => {
11670
11848
  const postable = postableAccounts(accounts);
11671
11849
  if (!counterpartyAccounting.limitAccounts) return postable;
11672
11850
  const allowed = new Set(counterpartyAccounting.allowedAccountIds);
11673
11851
  return postable.filter((account) => allowed.has(account.id));
11674
11852
  }, [accounts, counterpartyAccounting]);
11675
11853
  const hasItemLookups = itemTypes.some((item) => entityLookups[item.value]);
11676
- const amount = React25__default.useMemo(() => items.reduce(
11854
+ const amount = React26__default.useMemo(() => items.reduce(
11677
11855
  (sum, item) => sum + item.quantity * item.unitPrice - (item.discount ?? 0) + (item.surcharge ?? 0),
11678
11856
  0
11679
11857
  ), [items]);
@@ -11681,8 +11859,8 @@ function RecurringBillFormView({ direction, valueId, onBack }) {
11681
11859
  const numericDueDay = Math.trunc(Number(dueDay));
11682
11860
  const invalid = items.length === 0 ? "Adicione pelo menos um item." : items.some((item) => !item.description.trim()) ? "Informe a descri\xE7\xE3o de todos os itens." : !contact?.id ? `Selecione ${isPayable ? "quem recebe" : "quem paga"}.` : !(amount > 0) ? "Informe um valor maior que zero." : numericDueDay < 1 || numericDueDay > 31 ? "O dia do vencimento vai de 1 a 31." : !startsOn ? "Informe o in\xEDcio da recorr\xEAncia." : endsOn && endsOn < startsOn ? "O t\xE9rmino n\xE3o pode vir antes do in\xEDcio." : null;
11683
11861
  const fields = { items, contactId: contact?.id ?? "", frequency, dueDay, startsOn, endsOn, unitId, notes, isActive };
11684
- const snapshot = React25__default.useRef(null);
11685
- React25__default.useEffect(() => {
11862
+ const snapshot = React26__default.useRef(null);
11863
+ React26__default.useEffect(() => {
11686
11864
  if (loaded && snapshot.current === null) snapshot.current = JSON.stringify(fields);
11687
11865
  }, [loaded]);
11688
11866
  const dirty = snapshot.current !== null && JSON.stringify(fields) !== snapshot.current;
@@ -12529,7 +12707,7 @@ function BalanceHero() {
12529
12707
  const summary = useFinancialStore((s) => s.summary);
12530
12708
  const navigate = useDashboardNavigate();
12531
12709
  useEnsureSummary();
12532
- const [hidden, setHidden] = React25__default.useState(false);
12710
+ const [hidden, setHidden] = React26__default.useState(false);
12533
12711
  const money4 = (v) => hidden ? "\u2022\u2022\u2022\u2022\u2022\u2022" : formatCurrency(v ?? 0, currency);
12534
12712
  const inflow = summary?.monthlyInflow ?? 0;
12535
12713
  const outflow = summary?.monthlyOutflow ?? 0;
@@ -12602,7 +12780,7 @@ function categorizeSpend(notes) {
12602
12780
  function SpendByCategoryChart() {
12603
12781
  const t2 = useTranslation();
12604
12782
  const provider = useFinancialProvider();
12605
- const [rows, setRows] = React25__default.useState([]);
12783
+ const [rows, setRows] = React26__default.useState([]);
12606
12784
  useEffect(() => {
12607
12785
  let alive = true;
12608
12786
  void (async () => {
@@ -12645,7 +12823,7 @@ function UpcomingBills() {
12645
12823
  const { currency } = useFinancialConfig();
12646
12824
  const provider = useFinancialProvider();
12647
12825
  const dueLabel = useDueLabel();
12648
- const [bills, setBills] = React25__default.useState([]);
12826
+ const [bills, setBills] = React26__default.useState([]);
12649
12827
  useEffect(() => {
12650
12828
  let alive = true;
12651
12829
  void (async () => {
@@ -14248,7 +14426,8 @@ function createSupabaseFinancialProvider() {
14248
14426
  createInvoice: (input) => provider.createInvoice(input),
14249
14427
  getMovements: (query) => provider.getMovements(query),
14250
14428
  payMovement: (input) => provider.payMovement(input),
14251
- reconcileMovement: ({ bankMovementId, matchedMovementId }) => markReconciled(bankMovementId, matchedMovementId)
14429
+ reconcileMovement: ({ bankMovementId, matchedMovementId }) => markReconciled(bankMovementId, matchedMovementId),
14430
+ addInstallmentCharge: (input) => provider.addInstallmentCharge(input)
14252
14431
  };
14253
14432
  }
14254
14433
  const provider = {
@@ -14397,7 +14576,9 @@ function createSupabaseFinancialProvider() {
14397
14576
  costCenterId: i.costCenterId,
14398
14577
  // De onde a linha veio — o pacote que a gerou, por exemplo. Sem
14399
14578
  // devolvê-lo, editar a fatura desfazia o vínculo em silêncio.
14400
- metadata: i.metadata ?? void 0,
14579
+ metadata: i.metadata ?? i.itemMetadata ?? void 0,
14580
+ cancelledAt: i.itemCancelledAt ?? void 0,
14581
+ cancelReason: i.itemCancelReason ?? void 0,
14401
14582
  createdAt: i.createdAt
14402
14583
  };
14403
14584
  });
@@ -14585,7 +14766,7 @@ function createSupabaseFinancialProvider() {
14585
14766
  const totalAmount = round26(items.reduce((sum, i) => sum + lineTotal(i), 0));
14586
14767
  const { error: totalErr } = await pub.from("plg_financial_invoices").update({ total: totalAmount, description: items.map((i) => i.description).filter(Boolean).join(", ") || null }).eq("id", invoiceId);
14587
14768
  if (totalErr) throw totalErr;
14588
- const { error: delErr } = await core.from("items").delete().eq("invoice_id", invoiceId);
14769
+ const { error: delErr } = await core.from("items").delete().eq("invoice_id", invoiceId).is("cancelled_at", null);
14589
14770
  if (delErr) throw delErr;
14590
14771
  if (items.length === 0) return;
14591
14772
  const { data: inserted, error: insErr } = await core.from("items").insert(
@@ -14639,6 +14820,57 @@ function createSupabaseFinancialProvider() {
14639
14820
  });
14640
14821
  if (error) throw error;
14641
14822
  },
14823
+ async listCancellationReasons() {
14824
+ const { pub } = getClients();
14825
+ const { data, error } = await pub.rpc("financial_cancellation_reasons");
14826
+ if (error) throw error;
14827
+ return (data ?? []).map((r) => ({ id: r.id, name: r.name }));
14828
+ },
14829
+ async getInvoiceStockEntries(invoiceId) {
14830
+ const { pub } = getClients();
14831
+ const { data, error } = await pub.rpc("financial_invoice_stock_entries", { p_invoice_id: invoiceId });
14832
+ if (error) throw error;
14833
+ return (data ?? []).map((r) => ({
14834
+ itemId: r.item_id,
14835
+ productId: r.product_id,
14836
+ locationId: r.location_id,
14837
+ entered: Number(r.entered ?? 0),
14838
+ available: Number(r.available ?? 0),
14839
+ enteredOn: r.entered_on
14840
+ }));
14841
+ },
14842
+ async cancelInvoiceItems(input) {
14843
+ const { pub } = getClients();
14844
+ const { data, error } = await pub.rpc("financial_cancel_invoice_items", {
14845
+ p_invoice_id: input.invoiceId,
14846
+ p_item_ids: input.itemIds.length ? input.itemIds : null,
14847
+ p_reason_id: input.reasonId,
14848
+ p_detail: input.detail?.trim() || null,
14849
+ p_stock: input.stock.map((s) => ({ item_id: s.itemId, destination: s.destination, note: s.note?.trim() || null })),
14850
+ p_idempotency_key: input.idempotencyKey ?? createIdempotencyKey("financial:invoice-cancel-items")
14851
+ });
14852
+ if (error) throw error;
14853
+ const r = data ?? {};
14854
+ return { whole: !!r.whole, amount: Number(r.amount ?? 0), creditAmount: Number(r.credit_amount ?? 0) };
14855
+ },
14856
+ async addInstallmentCharge(input) {
14857
+ const { pub } = getClients();
14858
+ const { error } = await pub.rpc("financial_add_installment_charge", {
14859
+ p_installment_id: input.installmentId,
14860
+ p_kind: input.kind,
14861
+ p_amount: input.amount,
14862
+ p_chart_account_id: input.accountId ?? null,
14863
+ p_description: input.description?.trim() || null,
14864
+ p_idempotency_key: input.idempotencyKey ?? createIdempotencyKey("financial:installment-charge")
14865
+ });
14866
+ if (error) throw error;
14867
+ },
14868
+ async getSupplierCredit(personId) {
14869
+ const { pub } = getClients();
14870
+ const { data, error } = await pub.rpc("financial_supplier_credit", { p_person_id: personId });
14871
+ if (error) throw error;
14872
+ return Number(data ?? 0);
14873
+ },
14642
14874
  // --- Movements (public.financial_movements) ---
14643
14875
  async getMovements(query) {
14644
14876
  const { pub } = getClients();
@@ -16996,21 +17228,21 @@ function ChartOfAccountsTree({ entity, mockData, dataProvider, embedded }) {
16996
17228
  const canCreate = can("financial", "create");
16997
17229
  const canEdit = can("financial", "edit");
16998
17230
  const canDelete = can("financial", "delete");
16999
- const provider = React25__default.useMemo(
17231
+ const provider = React26__default.useMemo(
17000
17232
  () => dataProvider ?? resolveDataProvider(entity, mockData ?? []),
17001
17233
  [entity, mockData, dataProvider]
17002
17234
  );
17003
17235
  const table = entity.data?.table;
17004
- const [nodes, setNodes] = React25__default.useState(null);
17005
- const [loadError, setLoadError] = React25__default.useState(null);
17006
- const [expanded, setExpanded] = React25__default.useState(/* @__PURE__ */ new Set());
17007
- const [query, setQuery] = React25__default.useState("");
17008
- const [showInactive, setShowInactive] = React25__default.useState(true);
17009
- const [editing, setEditing] = React25__default.useState(null);
17010
- const [confirmDelete, setConfirmDelete] = React25__default.useState(null);
17011
- const [busy, setBusy] = React25__default.useState(false);
17012
- const initialized = React25__default.useRef(false);
17013
- const reload = React25__default.useCallback(async () => {
17236
+ const [nodes, setNodes] = React26__default.useState(null);
17237
+ const [loadError, setLoadError] = React26__default.useState(null);
17238
+ const [expanded, setExpanded] = React26__default.useState(/* @__PURE__ */ new Set());
17239
+ const [query, setQuery] = React26__default.useState("");
17240
+ const [showInactive, setShowInactive] = React26__default.useState(true);
17241
+ const [editing, setEditing] = React26__default.useState(null);
17242
+ const [confirmDelete, setConfirmDelete] = React26__default.useState(null);
17243
+ const [busy, setBusy] = React26__default.useState(false);
17244
+ const initialized = React26__default.useRef(false);
17245
+ const reload = React26__default.useCallback(async () => {
17014
17246
  try {
17015
17247
  const rows = await loadAll(provider);
17016
17248
  setNodes(rows);
@@ -17025,17 +17257,17 @@ function ChartOfAccountsTree({ entity, mockData, dataProvider, embedded }) {
17025
17257
  setNodes((prev) => prev ?? []);
17026
17258
  }
17027
17259
  }, [provider]);
17028
- React25__default.useEffect(() => {
17260
+ React26__default.useEffect(() => {
17029
17261
  void reload();
17030
17262
  }, [reload]);
17031
- const shown = React25__default.useMemo(
17263
+ const shown = React26__default.useMemo(
17032
17264
  () => (nodes ?? []).filter((n) => showInactive || n.isActive),
17033
17265
  [nodes, showInactive]
17034
17266
  );
17035
- const tree = React25__default.useMemo(() => buildChartTree(shown), [shown]);
17036
- const fullTree = React25__default.useMemo(() => buildChartTree(nodes ?? []), [nodes]);
17037
- const search = React25__default.useMemo(() => searchVisibleIds(tree, query), [tree, query]);
17038
- const groupIds = React25__default.useMemo(
17267
+ const tree = React26__default.useMemo(() => buildChartTree(shown), [shown]);
17268
+ const fullTree = React26__default.useMemo(() => buildChartTree(nodes ?? []), [nodes]);
17269
+ const search = React26__default.useMemo(() => searchVisibleIds(tree, query), [tree, query]);
17270
+ const groupIds = React26__default.useMemo(
17039
17271
  () => [...tree.byId.values()].filter((n) => isGroupNode(tree, n)).map((n) => n.id),
17040
17272
  [tree]
17041
17273
  );
@@ -17131,7 +17363,7 @@ function ChartOfAccountsTree({ entity, mockData, dataProvider, embedded }) {
17131
17363
  const ok = await run(() => provider.remove(node.id), t2("financial.chartTree.deleted", { name: node.name }));
17132
17364
  if (ok) setConfirmDelete(null);
17133
17365
  }
17134
- const parentOptions = React25__default.useCallback((q, selfId) => {
17366
+ const parentOptions = React26__default.useCallback((q, selfId) => {
17135
17367
  const blocked = selfId ? descendantIds(fullTree, selfId).add(selfId) : /* @__PURE__ */ new Set();
17136
17368
  const needle = q.trim().toLowerCase();
17137
17369
  return [...fullTree.byId.values()].filter((n) => !blocked.has(n.id) && isGroupNode(fullTree, n)).filter((n) => !needle || accountLabel(n).toLowerCase().includes(needle)).sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true })).slice(0, 50).map((n) => ({ id: n.id, label: accountLabel(n) }));
@@ -17434,7 +17666,7 @@ var chartOfAccountsSeed = [
17434
17666
  { id: "coa-4.2", code: "4.2", name: "Utilities", nodeType: "leaf", accountType: "expense", parentId: "coa-4", isActive: true },
17435
17667
  { id: "coa-4.3", code: "4.3", name: "Payroll", nodeType: "leaf", accountType: "expense", parentId: "coa-4", isActive: true }
17436
17668
  ];
17437
- var ChartOfAccountsRegistryView = (props) => React25__default.createElement(ChartOfAccountsTree, { ...props, entity: chartOfAccountsEntity, mockData: chartOfAccountsSeed });
17669
+ var ChartOfAccountsRegistryView = (props) => React26__default.createElement(ChartOfAccountsTree, { ...props, entity: chartOfAccountsEntity, mockData: chartOfAccountsSeed });
17438
17670
  ChartOfAccountsRegistryView.displayName = "ChartOfAccountsRegistryView";
17439
17671
  var costCenterEntity = {
17440
17672
  name: "Cost Center",
@@ -17532,7 +17764,7 @@ var bankAccountEntity = {
17532
17764
  labelWhen: (v) => v.kind === "card_payable" ? "Banco emissor" : void 0,
17533
17765
  visibleWhen: (v) => hasIssuer(v.kind),
17534
17766
  group: "bank",
17535
- renderInput: ({ value, values, onChange, setValue }) => React25__default.createElement(BankPicker, {
17767
+ renderInput: ({ value, values, onChange, setValue }) => React26__default.createElement(BankPicker, {
17536
17768
  code: values.bankCode ?? "",
17537
17769
  name: value ?? "",
17538
17770
  onChange: (next) => {
@@ -18401,10 +18633,10 @@ function CashSessionHeaderControl({ config, provider, store: store6 }) {
18401
18633
  const fetchCashSessions = useStore(store6, (s) => s.fetchCashSessions);
18402
18634
  const open = useStore(store6, (s) => s.cashSessionModalOpen);
18403
18635
  const setOpen = useStore(store6, (s) => s.setCashSessionModalOpen);
18404
- React25__default.useEffect(() => {
18636
+ React26__default.useEffect(() => {
18405
18637
  void fetchCashSessions?.();
18406
18638
  }, [fetchCashSessions]);
18407
- React25__default.useEffect(() => {
18639
+ React26__default.useEffect(() => {
18408
18640
  if (!open) return;
18409
18641
  const close = () => setOpen(false);
18410
18642
  window.addEventListener("hashchange", close);
@@ -20425,14 +20657,14 @@ function CommissionRuleDialog({
20425
20657
  onClose,
20426
20658
  onSaved
20427
20659
  }) {
20428
- const [name, setName] = React25__default.useState(rule?.name ?? "");
20429
- const [description, setDescription] = React25__default.useState(rule?.description ?? "");
20430
- const [priority, setPriority] = React25__default.useState(String(rule?.priority ?? 1));
20431
- const [isActive, setIsActive] = React25__default.useState(rule?.isActive ?? true);
20432
- const [isDefault, setIsDefault] = React25__default.useState(rule?.isDefault ?? !hasDefault);
20433
- const [saving, setSaving] = React25__default.useState(false);
20434
- const [error, setError] = React25__default.useState(null);
20435
- const initial = React25__default.useMemo(() => {
20660
+ const [name, setName] = React26__default.useState(rule?.name ?? "");
20661
+ const [description, setDescription] = React26__default.useState(rule?.description ?? "");
20662
+ const [priority, setPriority] = React26__default.useState(String(rule?.priority ?? 1));
20663
+ const [isActive, setIsActive] = React26__default.useState(rule?.isActive ?? true);
20664
+ const [isDefault, setIsDefault] = React26__default.useState(rule?.isDefault ?? !hasDefault);
20665
+ const [saving, setSaving] = React26__default.useState(false);
20666
+ const [error, setError] = React26__default.useState(null);
20667
+ const initial = React26__default.useMemo(() => {
20436
20668
  const get = (t2, key = "ids") => rule?.criteria.find((c) => c.criteriaType === t2)?.criteriaValue?.[key] ?? [];
20437
20669
  return {
20438
20670
  services: get("service"),
@@ -20443,12 +20675,12 @@ function CommissionRuleDialog({
20443
20675
  ranges: get("time_range", "ranges")
20444
20676
  };
20445
20677
  }, [rule]);
20446
- const [services, setServices] = React25__default.useState(initial.services);
20447
- const [categories, setCategories] = React25__default.useState(initial.categories);
20448
- const [professionals, setProfessionals] = React25__default.useState(initial.professionals);
20449
- const [units, setUnits] = React25__default.useState(initial.units);
20450
- const [weekdays, setWeekdays] = React25__default.useState(initial.weekdays);
20451
- const [ranges, setRanges] = React25__default.useState(initial.ranges);
20678
+ const [services, setServices] = React26__default.useState(initial.services);
20679
+ const [categories, setCategories] = React26__default.useState(initial.categories);
20680
+ const [professionals, setProfessionals] = React26__default.useState(initial.professionals);
20681
+ const [units, setUnits] = React26__default.useState(initial.units);
20682
+ const [weekdays, setWeekdays] = React26__default.useState(initial.weekdays);
20683
+ const [ranges, setRanges] = React26__default.useState(initial.ranges);
20452
20684
  const criteriaCount = [services, categories, professionals, units].filter((a) => a.length > 0).length + (weekdays.length ? 1 : 0) + (ranges.length ? 1 : 0);
20453
20685
  async function handleSave() {
20454
20686
  if (!name.trim()) {
@@ -20642,7 +20874,7 @@ function CommissionRuleDialog({
20642
20874
  ] }),
20643
20875
  error && /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: error })
20644
20876
  ] }) }),
20645
- /* @__PURE__ */ jsxs(ModalFooter, { className: "border-t px-5 py-3", children: [
20877
+ /* @__PURE__ */ jsxs(ModalFooter, { variant: "bar", children: [
20646
20878
  /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: onClose, children: "Cancelar" }),
20647
20879
  /* @__PURE__ */ jsx(Button, { size: "sm", onClick: handleSave, disabled: saving || !name.trim(), children: saving ? "Salvando\u2026" : "Salvar" })
20648
20880
  ] })
@@ -20691,11 +20923,11 @@ function blankFunction(order) {
20691
20923
  };
20692
20924
  }
20693
20925
  function CommissionFunctionsDialog({ rule, provider, onClose, onSaved }) {
20694
- const [functions, setFunctions] = React25__default.useState(
20926
+ const [functions, setFunctions] = React26__default.useState(
20695
20927
  rule.functions.length ? rule.functions : [blankFunction(0)]
20696
20928
  );
20697
- const [saving, setSaving] = React25__default.useState(false);
20698
- const [error, setError] = React25__default.useState(null);
20929
+ const [saving, setSaving] = React26__default.useState(false);
20930
+ const [error, setError] = React26__default.useState(null);
20699
20931
  function patch(i, changes) {
20700
20932
  setFunctions((fs) => fs.map((f, j) => j === i ? { ...f, ...changes } : f));
20701
20933
  }
@@ -20823,7 +21055,7 @@ function CommissionFunctionsDialog({ rule, provider, onClose, onSaved }) {
20823
21055
  ] }),
20824
21056
  error && /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: error })
20825
21057
  ] }) }),
20826
- /* @__PURE__ */ jsxs(ModalFooter, { className: "border-t px-5 py-3", children: [
21058
+ /* @__PURE__ */ jsxs(ModalFooter, { variant: "bar", children: [
20827
21059
  /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: onClose, children: "Fechar" }),
20828
21060
  /* @__PURE__ */ jsx(Button, { size: "sm", onClick: handleSave, disabled: saving, children: saving ? "Salvando\u2026" : "Salvar" })
20829
21061
  ] })
@@ -20979,19 +21211,19 @@ function functionChip(fn) {
20979
21211
  return fn.valueType === "percentage" ? `${who}: ${fn.valueAmount}%` : `${who}: R$ ${fn.valueAmount.toFixed(2)}`;
20980
21212
  }
20981
21213
  function CommissionRulesView({ provider }) {
20982
- const [rules, setRules] = React25__default.useState(null);
20983
- const [catalogs, setCatalogs] = React25__default.useState({
21214
+ const [rules, setRules] = React26__default.useState(null);
21215
+ const [catalogs, setCatalogs] = React26__default.useState({
20984
21216
  services: [],
20985
21217
  categories: [],
20986
21218
  professionals: [],
20987
21219
  units: []
20988
21220
  });
20989
- const [error, setError] = React25__default.useState(null);
20990
- const [funnel, setFunnel] = React25__default.useState({});
20991
- const [editing, setEditing] = React25__default.useState(null);
20992
- const [functionsFor, setFunctionsFor] = React25__default.useState(null);
20993
- const [deleting, setDeleting] = React25__default.useState(null);
20994
- const load = React25__default.useCallback(async () => {
21221
+ const [error, setError] = React26__default.useState(null);
21222
+ const [funnel, setFunnel] = React26__default.useState({});
21223
+ const [editing, setEditing] = React26__default.useState(null);
21224
+ const [functionsFor, setFunctionsFor] = React26__default.useState(null);
21225
+ const [deleting, setDeleting] = React26__default.useState(null);
21226
+ const load = React26__default.useCallback(async () => {
20995
21227
  try {
20996
21228
  setError(null);
20997
21229
  const [r, c] = await Promise.all([
@@ -21005,17 +21237,17 @@ function CommissionRulesView({ provider }) {
21005
21237
  setRules([]);
21006
21238
  }
21007
21239
  }, [provider]);
21008
- React25__default.useEffect(() => {
21240
+ React26__default.useEffect(() => {
21009
21241
  void load();
21010
21242
  }, [load]);
21011
21243
  const funnelActive = Object.values(funnel).some(Boolean);
21012
- const visible = React25__default.useMemo(() => {
21244
+ const visible = React26__default.useMemo(() => {
21013
21245
  if (!rules) return [];
21014
21246
  const ranked = rankRules(rules);
21015
21247
  if (!funnelActive) return ranked;
21016
21248
  return ranked.filter((r) => r.isDefault || ruleMatches(r, funnel, catalogs));
21017
21249
  }, [rules, funnel, funnelActive, catalogs]);
21018
- const winner = React25__default.useMemo(() => {
21250
+ const winner = React26__default.useMemo(() => {
21019
21251
  if (!funnelActive) return null;
21020
21252
  return visible.find((r) => r.isActive && !r.isDefault) ?? visible.find((r) => r.isActive && r.isDefault) ?? null;
21021
21253
  }, [visible, funnelActive]);
@@ -21030,7 +21262,7 @@ function CommissionRulesView({ provider }) {
21030
21262
  await provider.duplicateCommissionRule?.(rule.id);
21031
21263
  await load();
21032
21264
  }
21033
- const headerActions = React25__default.useMemo(() => [{
21265
+ const headerActions = React26__default.useMemo(() => [{
21034
21266
  id: "new-rule",
21035
21267
  label: "Adicionar regra",
21036
21268
  icon: Plus,
@@ -21812,7 +22044,7 @@ function PersonStatementWidget({ person, provider, config }) {
21812
22044
  ] });
21813
22045
  }
21814
22046
  function SupplierFinancialSettings({ person, provider }) {
21815
- const initial = React25__default.useMemo(() => counterpartyAccountingPolicy(person), [person]);
22047
+ const initial = React26__default.useMemo(() => counterpartyAccountingPolicy(person), [person]);
21816
22048
  const [accounts, setAccounts] = useState([]);
21817
22049
  const [costCenters, setCostCenters] = useState([]);
21818
22050
  const [defaultAccountId, setDefaultAccountId] = useState(initial.defaultAccountId ?? "");
@@ -32908,6 +33140,20 @@ var MIGRATION_038_A_LINHA_DA_FATURA_ALCANCA_QUEM_VEIO_PELO_PEDIDO = `-- A linha
32908
33140
  -- exige, e manter a assinatura \xE9 o que deixa esta corre\xE7\xE3o ser um replace e n\xE3o
32909
33141
  -- um drop com depend\xEAncia quebrada.
32910
33142
 
33143
+ -- Replay guard. financial/120 appended item_cancelled_at, item_cancel_reason and
33144
+ -- item_metadata to this view; re-running this narrower list over that shape
33145
+ -- fails with \xABcannot drop columns from view\xBB. Once the view has them it is left
33146
+ -- as 120 made it. The invoker setting and the grant below are idempotent.
33147
+ DO $guard$
33148
+ BEGIN
33149
+ IF EXISTS (SELECT 1 FROM pg_attribute
33150
+ WHERE attrelid = to_regclass('public.v_financial_invoice_lines')
33151
+ AND attname = 'item_cancelled_at' AND NOT attisdropped) THEN
33152
+ RAISE NOTICE 'financial 038: v_financial_invoice_lines already has the 120 shape; left as it is';
33153
+ RETURN;
33154
+ END IF;
33155
+
33156
+ EXECUTE $view$
32911
33157
  CREATE OR REPLACE VIEW public.v_financial_invoice_lines AS
32912
33158
  -- \u2500\u2500 caminho 1: a linha nasceu no pr\xF3prio t\xEDtulo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
32913
33159
  SELECT i.id AS item_id,
@@ -32976,13 +33222,17 @@ SELECT i.id AS item_id,
32976
33222
  -- PART of an order (financial_invoice_from_lines) has its own lines, and
32977
33223
  -- without this guard it also listed every line of the order \u2014 the whole
32978
33224
  -- sale reported as billed, and summed twice.
32979
- AND NOT EXISTS (SELECT 1 FROM public.items own WHERE own.invoice_id = f.id);
33225
+ AND NOT EXISTS (SELECT 1 FROM public.items own WHERE own.invoice_id = f.id)
33226
+ $view$;
32980
33227
 
33228
+ EXECUTE $comment$
32981
33229
  COMMENT ON VIEW public.v_financial_invoice_lines IS
32982
33230
  'Linha de fatura com classifica\xE7\xE3o cont\xE1bil, pelos DOIS caminhos: a linha '
32983
33231
  'criada no t\xEDtulo (items.invoice_id) e a criada na venda (items.order_id = '
32984
33232
  'plg_financial_invoices.order_id). O segundo \xE9 100% do que o porte do V1 traz '
32985
- '\u2014 items_one_parent pro\xEDbe um item de ter os dois pais.';
33233
+ '\u2014 items_one_parent pro\xEDbe um item de ter os dois pais.'
33234
+ $comment$;
33235
+ END $guard$;
32986
33236
 
32987
33237
  -- \`security_invoker\` precisa sobreviver ao replace: sem ele a view passaria a
32988
33238
  -- rodar com os direitos do dono e furaria a RLS de todo mundo que a l\xEA.
@@ -45327,6 +45577,924 @@ END $function$;
45327
45577
  COMMENT ON FUNCTION public.financial_reschedule_invoice(uuid, jsonb, text) IS
45328
45578
  'Rewrites the schedule by UPDATING each numbered installment in place, and retires an unpaid legacy bill mirror instead of refusing (119).';
45329
45579
  `;
45580
+ var MIGRATION_120_CANCELAR_E_POR_ITEM_COM_MOTIVO_E_DESTINO = `-- Cancelar uma conta \xE9 escolher O QUE se cancela, POR QU\xCA e o que acontece
45581
+ -- com a mercadoria \u2014 e o que j\xE1 foi pago vira cr\xE9dito, n\xE3o some.
45582
+ --
45583
+ -- Contrato: V1, \`financeiro/contas-a-pagar-cancelamento/FLOW.md\` (09-10-2026).
45584
+ -- At\xE9 aqui o V2 cancelava s\xF3 a conta inteira, com motivo em texto livre, e
45585
+ -- RECUSAVA conta com pagamento. O V1 faz quatro coisas que este arquivo traz:
45586
+ --
45587
+ -- 1. cancela por ITEM \u2014 corta a parte n\xE3o paga das parcelas, da \xFAltima para
45588
+ -- tr\xE1s; s\xF3 depois libera o pago, do pagamento mais recente para tr\xE1s;
45589
+ -- 2. o motivo \xE9 uma LISTA da casa (Outros, Solicitado pelo cliente,
45590
+ -- Solicitado pelo profissional) + um detalhe;
45591
+ -- 3. o que j\xE1 foi pago vira CR\xC9DITO da contraparte (fornecedor ou cliente);
45592
+ -- 4. mercadoria que entrou no estoque por aquela nota pede destino:
45593
+ -- devolver ao fornecedor / baixar como perda / manter.
45594
+ --
45595
+ -- E um defeito que estava esperando por isto: pagar uma conta a pagar com
45596
+ -- \xABcr\xE9dito\xBB conferia o saldo com o sinal do CLIENTE. Cr\xE9dito de fornecedor \xE9 o
45597
+ -- fornecedor nos devendo \u2014 d\xE9bito na conta da pessoa \u2014 e s\xF3 conta o que veio
45598
+ -- de um cancelamento (ver \xA78).
45599
+ --
45600
+ -- Idempotente.
45601
+
45602
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45603
+ -- 1. Os motivos
45604
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45605
+
45606
+ CREATE TABLE IF NOT EXISTS public.plg_financial_cancellation_reasons (
45607
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
45608
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
45609
+ name text NOT NULL CHECK (btrim(name) <> ''),
45610
+ is_active boolean NOT NULL DEFAULT true,
45611
+ created_at timestamptz NOT NULL DEFAULT now(),
45612
+ updated_at timestamptz NOT NULL DEFAULT now()
45613
+ );
45614
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_financial_cancellation_reasons_name_uq
45615
+ ON public.plg_financial_cancellation_reasons (tenant_id, lower(btrim(name)));
45616
+
45617
+ DO $$
45618
+ BEGIN
45619
+ CREATE TRIGGER plg_financial_cancellation_reasons_updated_at
45620
+ BEFORE UPDATE ON public.plg_financial_cancellation_reasons
45621
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
45622
+ EXCEPTION WHEN duplicate_object THEN NULL;
45623
+ END $$;
45624
+
45625
+ SELECT app.scaffold_table('public.plg_financial_cancellation_reasons'::regclass,
45626
+ 'financial.invoice', 'financial', false, 'enforce');
45627
+ SELECT app.scaffold_scope('public.plg_financial_cancellation_reasons'::regclass, 'tenant', true);
45628
+
45629
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45630
+ -- 2. O registro de cada cancelamento
45631
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45632
+
45633
+ CREATE TABLE IF NOT EXISTS public.plg_financial_cancellations (
45634
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
45635
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
45636
+ unit_id uuid,
45637
+ invoice_id uuid NOT NULL REFERENCES public.plg_financial_invoices(id) ON DELETE CASCADE,
45638
+ whole boolean NOT NULL,
45639
+ item_ids uuid[] NOT NULL DEFAULT '{}',
45640
+ reason_id uuid REFERENCES public.plg_financial_cancellation_reasons(id) ON DELETE SET NULL,
45641
+ reason text NOT NULL,
45642
+ amount numeric(14,2) NOT NULL,
45643
+ credit_amount numeric(14,2) NOT NULL DEFAULT 0,
45644
+ credit_movement_id uuid REFERENCES public.plg_financial_movements(id) ON DELETE SET NULL,
45645
+ -- [{item_id, product_id, destination, quantity, note, operation_ids}]
45646
+ stock jsonb NOT NULL DEFAULT '[]'::jsonb,
45647
+ idempotency_key text,
45648
+ created_by uuid,
45649
+ created_at timestamptz NOT NULL DEFAULT now(),
45650
+ updated_at timestamptz NOT NULL DEFAULT now()
45651
+ );
45652
+ CREATE INDEX IF NOT EXISTS plg_financial_cancellations_invoice_idx
45653
+ ON public.plg_financial_cancellations (invoice_id);
45654
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_financial_cancellations_key_uq
45655
+ ON public.plg_financial_cancellations (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
45656
+
45657
+ DO $$
45658
+ BEGIN
45659
+ ALTER TABLE public.plg_financial_cancellations
45660
+ ADD CONSTRAINT plg_financial_cancellations_unit_fkey
45661
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
45662
+ EXCEPTION WHEN duplicate_object THEN NULL;
45663
+ END $$;
45664
+
45665
+ DO $$
45666
+ BEGIN
45667
+ CREATE TRIGGER plg_financial_cancellations_updated_at
45668
+ BEFORE UPDATE ON public.plg_financial_cancellations
45669
+ FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
45670
+ EXCEPTION WHEN duplicate_object THEN NULL;
45671
+ END $$;
45672
+
45673
+ SELECT app.scaffold_table('public.plg_financial_cancellations'::regclass,
45674
+ 'financial.invoice', 'financial', false, 'enforce');
45675
+ SELECT app.scaffold_scope('public.plg_financial_cancellations'::regclass, 'unit_optional', true);
45676
+
45677
+ ALTER TABLE public.plg_financial_invoices
45678
+ ADD COLUMN IF NOT EXISTS cancel_reason_id uuid REFERENCES public.plg_financial_cancellation_reasons(id) ON DELETE SET NULL;
45679
+
45680
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45681
+ -- 3. A linha diz que foi cancelada
45682
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45683
+ -- Colunas novas s\xF3 no FIM: \`CREATE OR REPLACE VIEW\` n\xE3o aceita outra ordem.
45684
+
45685
+ CREATE OR REPLACE VIEW public.v_financial_invoice_lines AS
45686
+ SELECT i.id AS item_id, i.tenant_id, i.unit_id, i.invoice_id, f.number AS invoice_number,
45687
+ f.direction, f.issue_date, f.cancelled_at, i.product_id, i.name, i.description,
45688
+ i.quantity, i.unit_price, i.discount, i.total,
45689
+ COALESCE(x.chart_account_id, i.chart_account_id) AS chart_account_id,
45690
+ COALESCE(x.cost_center_id, i.cost_center_id) AS cost_center_id,
45691
+ x.tax_amount, x.cfop, x.ncm,
45692
+ a.from_item_id AS billed_from_order_item_id, a.quantity AS billed_quantity,
45693
+ i.cancelled_at AS item_cancelled_at, i.cancel_reason AS item_cancel_reason, i.metadata AS item_metadata
45694
+ FROM public.items i
45695
+ JOIN public.plg_financial_invoices f ON f.id = i.invoice_id
45696
+ LEFT JOIN public.plg_financial_invoice_items x ON x.item_id = i.id
45697
+ LEFT JOIN public.item_associations a ON a.to_item_id = i.id
45698
+ WHERE i.invoice_id IS NOT NULL
45699
+ UNION ALL
45700
+ SELECT i.id AS item_id, i.tenant_id, i.unit_id, f.id AS invoice_id, f.number AS invoice_number,
45701
+ f.direction, f.issue_date, f.cancelled_at, i.product_id, i.name, i.description,
45702
+ i.quantity, i.unit_price, i.discount, i.total,
45703
+ COALESCE(x.chart_account_id, i.chart_account_id) AS chart_account_id,
45704
+ COALESCE(x.cost_center_id, i.cost_center_id) AS cost_center_id,
45705
+ x.tax_amount, x.cfop, x.ncm,
45706
+ a.from_item_id AS billed_from_order_item_id, a.quantity AS billed_quantity,
45707
+ i.cancelled_at AS item_cancelled_at, i.cancel_reason AS item_cancel_reason, i.metadata AS item_metadata
45708
+ FROM public.items i
45709
+ JOIN public.plg_financial_invoices f ON f.order_id = i.order_id
45710
+ LEFT JOIN public.plg_financial_invoice_items x ON x.item_id = i.id
45711
+ LEFT JOIN public.item_associations a ON a.to_item_id = i.id
45712
+ WHERE i.invoice_id IS NULL AND i.order_id IS NOT NULL
45713
+ AND NOT EXISTS (SELECT 1 FROM public.items own WHERE own.invoice_id = f.id);
45714
+
45715
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45716
+ -- 4. A licen\xE7a de emendar uma conta paga
45717
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45718
+ -- Conta com pagamento \xE9 imut\xE1vel (099/110) \u2014 certo para quem edita a conta,
45719
+ -- errado para quem a CANCELA ou lan\xE7a o juros que o banco cobrou. As duas RPCs
45720
+ -- deste arquivo ligam \`financial.amend\` na pr\xF3pria transa\xE7\xE3o; nenhum outro
45721
+ -- caminho liga.
45722
+
45723
+ CREATE OR REPLACE FUNCTION public.trg_invoice_line_immutable() RETURNS trigger
45724
+ LANGUAGE plpgsql SECURITY DEFINER
45725
+ SET search_path TO ''
45726
+ AS $$
45727
+ DECLARE v_issued date; v_cancelled timestamptz; v_invoice uuid; v_paid numeric;
45728
+ BEGIN
45729
+ IF coalesce(current_setting('financial.amend', true), '') = 'on' THEN RETURN coalesce(NEW, OLD); END IF;
45730
+ v_invoice := coalesce(NEW.invoice_id, OLD.invoice_id);
45731
+ IF v_invoice IS NULL THEN RETURN coalesce(NEW, OLD); END IF;
45732
+ SELECT f.issue_date, f.cancelled_at INTO v_issued, v_cancelled
45733
+ FROM public.plg_financial_invoices f WHERE f.id = v_invoice;
45734
+ IF v_issued IS NOT NULL AND v_cancelled IS NULL THEN
45735
+ SELECT coalesce(sum(b.paid), 0) INTO v_paid
45736
+ FROM public.v_financial_installment_balances b WHERE b.invoice_id = v_invoice;
45737
+ IF v_paid > 0 THEN
45738
+ RAISE EXCEPTION 'items: the lines of invoice % cannot be altered \u2014 % already received', v_invoice, v_paid
45739
+ USING ERRCODE = '23514',
45740
+ HINT = 'A paid invoice is immutable (PRD 04). Reverse the payments, or raise a new invoice.';
45741
+ END IF;
45742
+ END IF;
45743
+ RETURN coalesce(NEW, OLD);
45744
+ END $$;
45745
+
45746
+ CREATE OR REPLACE FUNCTION public.trg_plg_financial_invoices_immutable()
45747
+ RETURNS trigger
45748
+ LANGUAGE plpgsql
45749
+ SECURITY DEFINER
45750
+ SET search_path TO ''
45751
+ AS $function$
45752
+ DECLARE v_paid numeric;
45753
+ BEGIN
45754
+ IF OLD.issue_date IS NULL OR OLD.cancelled_at IS NOT NULL THEN RETURN NEW; END IF;
45755
+ IF NEW.cancelled_at IS NOT NULL AND OLD.cancelled_at IS NULL THEN RETURN NEW; END IF;
45756
+
45757
+ IF NEW.direction IS DISTINCT FROM OLD.direction
45758
+ OR NEW.order_id IS DISTINCT FROM OLD.order_id
45759
+ OR NEW.number IS DISTINCT FROM OLD.number
45760
+ OR NEW.issue_date IS DISTINCT FROM OLD.issue_date
45761
+ OR NEW.currency IS DISTINCT FROM OLD.currency THEN
45762
+ RAISE EXCEPTION 'plg_financial_invoices: invoice % is issued and cannot be altered', OLD.number
45763
+ USING ERRCODE = '23514',
45764
+ HINT = 'A closed invoice is immutable (PRD 04). Correct it with a new invoice, or cancel this one through financial_cancel_invoice.';
45765
+ END IF;
45766
+
45767
+ -- S\xF3 o TOTAL ganha a licen\xE7a: cancelar itens e lan\xE7ar juros mudam quanto a
45768
+ -- conta vale; compet\xEAncia e contraparte continuam travadas.
45769
+ IF NEW.competence_date IS DISTINCT FROM OLD.competence_date
45770
+ OR (NEW.total IS DISTINCT FROM OLD.total AND coalesce(current_setting('financial.amend', true), '') <> 'on')
45771
+ OR NEW.counterparty_person_id IS DISTINCT FROM OLD.counterparty_person_id THEN
45772
+ SELECT coalesce(sum(b.paid), 0) INTO v_paid
45773
+ FROM public.v_financial_installment_balances b WHERE b.invoice_id = OLD.id;
45774
+ IF v_paid > 0 THEN
45775
+ RAISE EXCEPTION 'plg_financial_invoices: invoice % has % already received and cannot be rewritten', OLD.number, v_paid
45776
+ USING ERRCODE = '23514',
45777
+ HINT = 'Reverse the payments first, or correct it with a new invoice.';
45778
+ END IF;
45779
+ END IF;
45780
+
45781
+ RETURN NEW;
45782
+ END $function$;
45783
+
45784
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45785
+ -- 5. Os motivos da casa, semeados na primeira pergunta
45786
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45787
+
45788
+ CREATE OR REPLACE FUNCTION public.financial_cancellation_reasons()
45789
+ RETURNS SETOF public.plg_financial_cancellation_reasons
45790
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
45791
+ DECLARE v_tenant uuid := public.plg_financial_require_tenant();
45792
+ BEGIN
45793
+ PERFORM public.plg_financial_require_permission('financial.read');
45794
+ IF NOT EXISTS (SELECT 1 FROM public.plg_financial_cancellation_reasons r WHERE r.tenant_id = v_tenant) THEN
45795
+ INSERT INTO public.plg_financial_cancellation_reasons (tenant_id, name)
45796
+ SELECT v_tenant, n FROM unnest(ARRAY['Outros', 'Solicitado pelo cliente', 'Solicitado pelo profissional']) n
45797
+ ON CONFLICT DO NOTHING;
45798
+ END IF;
45799
+ RETURN QUERY SELECT * FROM public.plg_financial_cancellation_reasons r
45800
+ WHERE r.tenant_id = v_tenant AND r.is_active ORDER BY r.name;
45801
+ END $$;
45802
+ REVOKE ALL ON FUNCTION public.financial_cancellation_reasons() FROM PUBLIC;
45803
+ GRANT EXECUTE ON FUNCTION public.financial_cancellation_reasons() TO authenticated, service_role;
45804
+
45805
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45806
+ -- 6. De onde veio a mercadoria de cada linha
45807
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45808
+ -- A nota \xE9 a ponte: a entrada no estoque diz \xABEntrada via DANFE NF-e <n\xBA>\xBB
45809
+ -- (fiscal-br/015, e as linhas migradas do V1 j\xE1 diziam). O n\xFAmero sai da nota
45810
+ -- recebida pelo V2 ou, na conta que veio do V1, do pedido de compra. O produto
45811
+ -- casa pelo id e, se o V1 tiver duplicado o cadastro, pelo nome.
45812
+ --
45813
+ -- Leitura din\xE2mica: o financeiro n\xE3o depende fisicamente do estoque; sem o
45814
+ -- plugin de estoque, nenhuma linha est\xE1 \xABno estoque\xBB.
45815
+
45816
+ CREATE OR REPLACE FUNCTION public.financial_invoice_stock_entries(p_invoice_id uuid)
45817
+ RETURNS TABLE (item_id uuid, product_id uuid, location_id uuid, entered numeric, available numeric, entered_on date)
45818
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO '' AS $$
45819
+ DECLARE
45820
+ v_tenant uuid := public.plg_financial_require_tenant();
45821
+ v_inv public.plg_financial_invoices%ROWTYPE;
45822
+ v_nf text;
45823
+ BEGIN
45824
+ SELECT * INTO v_inv FROM public.plg_financial_invoices WHERE id = p_invoice_id AND tenant_id = v_tenant;
45825
+ IF NOT FOUND OR v_inv.direction <> 'payable' THEN RETURN; END IF;
45826
+ PERFORM public.plg_financial_require_permission('financial.read', v_inv.unit_id);
45827
+ IF to_regclass('public.plg_inventory_stock_movements') IS NULL THEN RETURN; END IF;
45828
+
45829
+ IF to_regclass('public.plg_fiscalbr_inbox_documents') IS NOT NULL THEN
45830
+ EXECUTE 'SELECT d.nfe_number FROM public.plg_fiscalbr_inbox_documents d WHERE d.tenant_id = $1 AND d.invoice_id = $2 LIMIT 1'
45831
+ INTO v_nf USING v_tenant, p_invoice_id;
45832
+ END IF;
45833
+ IF v_nf IS NULL AND v_inv.order_id IS NOT NULL THEN
45834
+ SELECT nullif(btrim(o.reference_number), '') INTO v_nf FROM public.orders o WHERE o.id = v_inv.order_id;
45835
+ END IF;
45836
+ v_nf := coalesce(v_nf, nullif(btrim(v_inv.metadata ->> 'fiscalNumber'), ''));
45837
+ IF v_nf IS NULL THEN RETURN; END IF;
45838
+
45839
+ RETURN QUERY EXECUTE $q$
45840
+ WITH lines AS (
45841
+ SELECT l.item_id, l.product_id, lower(btrim(coalesce(p.name, l.name))) AS pname
45842
+ FROM public.v_financial_invoice_lines l
45843
+ LEFT JOIN public.products p ON p.id = l.product_id
45844
+ WHERE l.invoice_id = $2 AND l.item_cancelled_at IS NULL
45845
+ ), entries AS (
45846
+ SELECT m.product_id, m.source_location_id AS location_id, sum(m.quantity) AS qty,
45847
+ min(m.movement_date)::date AS entered_on, lower(btrim(p.name)) AS pname
45848
+ FROM public.plg_inventory_stock_movements m
45849
+ LEFT JOIN public.products p ON p.id = m.product_id
45850
+ WHERE m.tenant_id = $1 AND m.kind = 'in'
45851
+ AND (m.reason = 'Entrada via DANFE NF-e ' || $3 OR m.document_number = 'NF-e ' || $3)
45852
+ GROUP BY m.product_id, m.source_location_id, p.name
45853
+ ), by_product AS (
45854
+ SELECT l.item_id, e.* FROM lines l JOIN entries e ON e.product_id = l.product_id
45855
+ ), by_name AS (
45856
+ SELECT l.item_id, e.* FROM lines l JOIN entries e ON e.pname = l.pname
45857
+ WHERE l.item_id NOT IN (SELECT item_id FROM by_product)
45858
+ AND e.product_id NOT IN (SELECT product_id FROM by_product)
45859
+ ), rest_l AS (
45860
+ SELECT l.item_id FROM lines l
45861
+ WHERE l.item_id NOT IN (SELECT item_id FROM by_product UNION SELECT item_id FROM by_name)
45862
+ ), rest_e AS (
45863
+ SELECT e.* FROM entries e
45864
+ WHERE e.product_id NOT IN (SELECT product_id FROM by_product UNION SELECT product_id FROM by_name)
45865
+ ), by_elimination AS (
45866
+ -- The V1 sometimes booked a note's entry under another product record.
45867
+ -- One line and one product left over on the same note: they are the pair.
45868
+ SELECT l.item_id, e.* FROM rest_l l CROSS JOIN rest_e e
45869
+ WHERE (SELECT count(*) FROM rest_l) = 1 AND (SELECT count(DISTINCT product_id) FROM rest_e) = 1
45870
+ ), matched AS (
45871
+ SELECT * FROM by_product UNION ALL SELECT * FROM by_name UNION ALL SELECT * FROM by_elimination
45872
+ )
45873
+ SELECT m.item_id, m.product_id, m.location_id, m.qty,
45874
+ greatest(least(m.qty, coalesce((SELECT sum(sp.quantity) FROM public.plg_inventory_stock_positions sp
45875
+ WHERE sp.tenant_id = $1 AND sp.product_id = m.product_id
45876
+ AND sp.stock_location_id = m.location_id), 0)), 0),
45877
+ m.entered_on
45878
+ FROM matched m
45879
+ $q$ USING v_tenant, p_invoice_id, v_nf;
45880
+ END $$;
45881
+ REVOKE ALL ON FUNCTION public.financial_invoice_stock_entries(uuid) FROM PUBLIC;
45882
+ GRANT EXECUTE ON FUNCTION public.financial_invoice_stock_entries(uuid) TO authenticated, service_role;
45883
+
45884
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45885
+ -- 7. Cancelar \u2014 itens ou a conta inteira
45886
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
45887
+ -- p_item_ids vazio/nulo = todos. p_stock = [{item_id, destination: 'devolver'
45888
+ -- | 'perda' | 'manter', note}] \u2014 obrigat\xF3rio para cada linha que entrou no
45889
+ -- estoque; NULL s\xF3 pelo caminho antigo (financial_cancel_invoice), que nunca
45890
+ -- perguntou e continua sem mexer no estoque.
45891
+
45892
+ CREATE OR REPLACE FUNCTION public.financial_cancel_invoice_items(
45893
+ p_invoice_id uuid,
45894
+ p_item_ids uuid[] DEFAULT NULL,
45895
+ p_reason_id uuid DEFAULT NULL,
45896
+ p_detail text DEFAULT NULL,
45897
+ p_stock jsonb DEFAULT '[]'::jsonb,
45898
+ p_idempotency_key text DEFAULT NULL
45899
+ ) RETURNS jsonb
45900
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
45901
+ DECLARE
45902
+ v_tenant uuid := public.plg_financial_require_tenant();
45903
+ v_inv public.plg_financial_invoices%ROWTYPE;
45904
+ v_reason text;
45905
+ v_rname text;
45906
+ v_live uuid[];
45907
+ v_sel uuid[];
45908
+ v_whole boolean;
45909
+ v_amount numeric := 0;
45910
+ v_cut numeric;
45911
+ v_take numeric;
45912
+ v_credit numeric := 0;
45913
+ v_paid numeric;
45914
+ v_inst record;
45915
+ v_alloc record;
45916
+ v_entry record;
45917
+ v_dest jsonb;
45918
+ v_dest_k text;
45919
+ v_stock jsonb := '[]'::jsonb;
45920
+ v_op jsonb;
45921
+ v_ops jsonb;
45922
+ v_person uuid;
45923
+ v_cancel_acc uuid;
45924
+ v_mov uuid;
45925
+ v_log uuid;
45926
+ v_nf text;
45927
+ BEGIN
45928
+ SELECT * INTO v_inv FROM public.plg_financial_invoices WHERE id = p_invoice_id AND tenant_id = v_tenant FOR UPDATE;
45929
+ IF NOT FOUND THEN
45930
+ RAISE EXCEPTION 'financial_cancel_invoice_items: invoice % not found', p_invoice_id USING ERRCODE = 'P0002';
45931
+ END IF;
45932
+ PERFORM public.plg_financial_require_permission('financial.edit', v_inv.unit_id);
45933
+
45934
+ IF p_idempotency_key IS NOT NULL THEN
45935
+ SELECT c.id INTO v_log FROM public.plg_financial_cancellations c
45936
+ WHERE c.tenant_id = v_tenant AND c.idempotency_key = p_idempotency_key;
45937
+ IF v_log IS NOT NULL THEN
45938
+ RETURN (SELECT jsonb_build_object('cancellation_id', c.id, 'invoice_id', c.invoice_id, 'whole', c.whole,
45939
+ 'amount', c.amount, 'credit_amount', c.credit_amount, 'replayed', true)
45940
+ FROM public.plg_financial_cancellations c WHERE c.id = v_log);
45941
+ END IF;
45942
+ END IF;
45943
+ IF v_inv.cancelled_at IS NOT NULL THEN
45944
+ RAISE EXCEPTION 'financial_cancel_invoice_items: invoice % is already cancelled', v_inv.number USING ERRCODE = '55000';
45945
+ END IF;
45946
+
45947
+ -- O motivo: da lista, com o detalhe ao lado \u2014 \xABOutros \u2014 nota devolvida\xBB.
45948
+ IF p_reason_id IS NOT NULL THEN
45949
+ SELECT r.name INTO v_rname FROM public.plg_financial_cancellation_reasons r
45950
+ WHERE r.id = p_reason_id AND r.tenant_id = v_tenant;
45951
+ IF v_rname IS NULL THEN
45952
+ RAISE EXCEPTION 'financial_cancel_invoice_items: reason % not found', p_reason_id USING ERRCODE = 'P0002';
45953
+ END IF;
45954
+ END IF;
45955
+ v_reason := nullif(concat_ws(' \u2014 ', v_rname, nullif(btrim(coalesce(p_detail, '')), '')), '');
45956
+ IF v_reason IS NULL THEN
45957
+ RAISE EXCEPTION 'financial_cancel_invoice_items: a reason is required' USING ERRCODE = '22023';
45958
+ END IF;
45959
+
45960
+ SELECT coalesce(array_agg(l.item_id), '{}') INTO v_live
45961
+ FROM public.v_financial_invoice_lines l WHERE l.invoice_id = p_invoice_id AND l.item_cancelled_at IS NULL;
45962
+ IF p_item_ids IS NULL OR cardinality(p_item_ids) = 0 THEN
45963
+ v_sel := v_live;
45964
+ ELSE
45965
+ IF EXISTS (SELECT 1 FROM unnest(p_item_ids) x WHERE x <> ALL (v_live)) THEN
45966
+ RAISE EXCEPTION 'financial_cancel_invoice_items: an item is not a live line of invoice %', v_inv.number USING ERRCODE = '22023';
45967
+ END IF;
45968
+ SELECT array_agg(DISTINCT x) INTO v_sel FROM unnest(p_item_ids) x;
45969
+ END IF;
45970
+ v_whole := NOT EXISTS (SELECT 1 FROM unnest(v_live) x WHERE x <> ALL (v_sel));
45971
+
45972
+ -- Quanto: a soma das linhas; a conta inteira cancela o que a conta vale (o
45973
+ -- V1 tem conta cujas linhas n\xE3o somam o total, e o total \xE9 o que se deve).
45974
+ IF v_whole THEN
45975
+ v_amount := v_inv.total;
45976
+ ELSE
45977
+ SELECT round(coalesce(sum(l.total), 0), 2) INTO v_amount
45978
+ FROM public.v_financial_invoice_lines l WHERE l.invoice_id = p_invoice_id AND l.item_id = ANY (v_sel);
45979
+ v_amount := least(v_amount, v_inv.total);
45980
+ END IF;
45981
+
45982
+ PERFORM set_config('financial.amend', 'on', true);
45983
+
45984
+ -- \u2500\u2500 a mercadoria \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
45985
+ IF p_stock IS NOT NULL THEN
45986
+ FOR v_entry IN
45987
+ SELECT e.* FROM public.financial_invoice_stock_entries(p_invoice_id) e
45988
+ WHERE e.item_id = ANY (v_sel) ORDER BY e.item_id, e.entered_on DESC
45989
+ LOOP
45990
+ SELECT d INTO v_dest FROM jsonb_array_elements(p_stock) d WHERE d ->> 'item_id' = v_entry.item_id::text LIMIT 1;
45991
+ v_dest_k := v_dest ->> 'destination';
45992
+ IF v_dest_k IS NULL OR v_dest_k NOT IN ('devolver', 'perda', 'manter') THEN
45993
+ RAISE EXCEPTION 'financial_cancel_invoice_items: choose what happens to the goods of item % (devolver | perda | manter)', v_entry.item_id
45994
+ USING ERRCODE = '22023', HINT = 'ESCOLHA_DESTINO';
45995
+ END IF;
45996
+ v_op := NULL;
45997
+ IF v_dest_k <> 'manter' AND v_entry.available > 0 THEN
45998
+ EXECUTE 'SELECT public.inventory_adjust($1, $2, $3, $4)' INTO v_op
45999
+ USING v_entry.location_id,
46000
+ jsonb_build_array(jsonb_strip_nulls(jsonb_build_object(
46001
+ 'product_id', v_entry.product_id, 'quantity', -v_entry.available,
46002
+ 'notes', nullif(btrim(coalesce(v_dest ->> 'note', '')), '')))),
46003
+ CASE v_dest_k WHEN 'devolver' THEN 'Devolu\xE7\xE3o ao fornecedor' ELSE 'Perda de mercadoria da compra' END,
46004
+ 'cancel:' || p_invoice_id || ':' || v_entry.item_id || ':' || v_entry.location_id
46005
+ || coalesce(':' || p_idempotency_key, '');
46006
+ END IF;
46007
+ v_stock := v_stock || jsonb_build_object(
46008
+ 'item_id', v_entry.item_id, 'product_id', v_entry.product_id, 'location_id', v_entry.location_id,
46009
+ 'destination', v_dest_k, 'entered', v_entry.entered,
46010
+ 'moved', CASE WHEN v_op IS NULL THEN 0 ELSE v_entry.available END,
46011
+ 'note', nullif(btrim(coalesce(v_dest ->> 'note', '')), ''),
46012
+ 'operation_id', v_op ->> 'operation_id');
46013
+ END LOOP;
46014
+ END IF;
46015
+
46016
+ -- \u2500\u2500 as linhas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
46017
+ UPDATE public.items SET cancelled_at = now(), cancel_reason = v_reason
46018
+ WHERE id = ANY (v_sel) AND tenant_id = v_tenant AND cancelled_at IS NULL;
46019
+
46020
+ -- \u2500\u2500 o dinheiro \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
46021
+ IF v_whole THEN
46022
+ SELECT coalesce(sum(b.paid), 0) INTO v_paid FROM public.v_financial_installment_balances b WHERE b.invoice_id = p_invoice_id;
46023
+ v_credit := v_paid;
46024
+ UPDATE public.plg_financial_invoices
46025
+ SET cancelled_at = now(), cancel_reason = v_reason, cancel_reason_id = p_reason_id,
46026
+ metadata = metadata || jsonb_build_object('v2_decision', jsonb_build_object('cancelled', true, 'at', now()))
46027
+ WHERE id = p_invoice_id;
46028
+ UPDATE public.plg_financial_invoice_installments
46029
+ SET cancelled_at = now(),
46030
+ metadata = metadata || jsonb_build_object('v2_decision', jsonb_build_object('cancelled', true))
46031
+ WHERE invoice_id = p_invoice_id AND cancelled_at IS NULL;
46032
+ UPDATE public.plg_financial_movements SET status = 'cancelled', updated_at = now()
46033
+ WHERE financial_invoice_id = p_invoice_id AND movement_kind = 'bill' AND status <> 'cancelled';
46034
+ ELSE
46035
+ v_cut := v_amount;
46036
+ -- 1\xBA a parte N\xC3O paga, da \xFAltima parcela para tr\xE1s.
46037
+ FOR v_inst IN
46038
+ SELECT i.id, i.amount, public.plg_financial_installment_paid(i.id) AS paid
46039
+ FROM public.plg_financial_invoice_installments i
46040
+ WHERE i.invoice_id = p_invoice_id AND i.cancelled_at IS NULL
46041
+ ORDER BY i.number DESC
46042
+ LOOP
46043
+ EXIT WHEN v_cut <= 0;
46044
+ v_take := least(greatest(v_inst.amount - v_inst.paid, 0), v_cut);
46045
+ CONTINUE WHEN v_take <= 0;
46046
+ UPDATE public.plg_financial_invoice_installments
46047
+ SET amount = amount - v_take,
46048
+ cancelled_at = CASE WHEN amount - v_take <= 0 AND v_inst.paid = 0 THEN now() END,
46049
+ metadata = metadata || jsonb_build_object('v2_decision', jsonb_build_object('amended', true))
46050
+ WHERE id = v_inst.id;
46051
+ v_cut := v_cut - v_take;
46052
+ END LOOP;
46053
+ -- 2\xBA o que sobrou sai do pago: o pagamento mais recente primeiro, e o que
46054
+ -- ele deixa de quitar vira cr\xE9dito.
46055
+ FOR v_inst IN
46056
+ SELECT i.id, i.amount FROM public.plg_financial_invoice_installments i
46057
+ WHERE i.invoice_id = p_invoice_id AND i.cancelled_at IS NULL
46058
+ ORDER BY i.number DESC
46059
+ LOOP
46060
+ EXIT WHEN v_cut <= 0;
46061
+ FOR v_alloc IN
46062
+ SELECT a.id, a.amount FROM public.plg_financial_allocations a
46063
+ JOIN public.plg_financial_movements m ON m.id = a.payment_movement_id
46064
+ WHERE a.installment_id = v_inst.id AND m.status <> 'cancelled' AND m.reversed_by_id IS NULL
46065
+ ORDER BY m.payment_date DESC NULLS LAST, a.created_at DESC
46066
+ LOOP
46067
+ EXIT WHEN v_cut <= 0;
46068
+ v_take := least(v_alloc.amount, v_cut);
46069
+ IF v_take >= v_alloc.amount THEN
46070
+ DELETE FROM public.plg_financial_allocations WHERE id = v_alloc.id;
46071
+ ELSE
46072
+ UPDATE public.plg_financial_allocations SET amount = amount - v_take,
46073
+ metadata = metadata || jsonb_build_object('reduced_by_cancellation', v_take)
46074
+ WHERE id = v_alloc.id;
46075
+ END IF;
46076
+ UPDATE public.plg_financial_invoice_installments
46077
+ SET amount = amount - v_take,
46078
+ metadata = metadata || jsonb_build_object('v2_decision', jsonb_build_object('amended', true))
46079
+ WHERE id = v_inst.id;
46080
+ v_credit := v_credit + v_take;
46081
+ v_cut := v_cut - v_take;
46082
+ END LOOP;
46083
+ END LOOP;
46084
+ UPDATE public.plg_financial_invoices
46085
+ SET total = greatest(total - v_amount, 0),
46086
+ metadata = metadata || jsonb_build_object('v2_decision', jsonb_build_object('amended', true, 'at', now()))
46087
+ WHERE id = p_invoice_id;
46088
+ PERFORM public.plg_financial_sync_mirror(i.id)
46089
+ FROM public.plg_financial_invoice_installments i WHERE i.invoice_id = p_invoice_id;
46090
+ END IF;
46091
+
46092
+ -- \u2500\u2500 o cr\xE9dito \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
46093
+ -- Fornecedor: ele passa a nos dever \u2014 d\xE9bito na conta dele. Cliente: n\xF3s
46094
+ -- passamos a dever a ele \u2014 cr\xE9dito na conta dele (como financial_cancel_paid_item).
46095
+ v_credit := round(v_credit, 2);
46096
+ IF v_credit > 0 THEN
46097
+ IF v_inv.counterparty_person_id IS NULL THEN
46098
+ RAISE EXCEPTION 'financial_cancel_invoice_items: % was paid and the invoice has no counterparty to hold the credit', v_credit
46099
+ USING ERRCODE = '55000';
46100
+ END IF;
46101
+ v_person := public.plg_financial_person_account(v_tenant, v_inv.counterparty_person_id);
46102
+ v_cancel_acc := public.plg_financial_system_account(v_tenant, 'cancellations');
46103
+ INSERT INTO public.plg_financial_movements
46104
+ (tenant_id, unit_id, financial_invoice_id, direction, movement_kind, amount, paid_amount, status, due_date, payment_date,
46105
+ method, reason, metadata, idempotency_key, created_by, debit_account_id, credit_account_id)
46106
+ VALUES
46107
+ (v_tenant, v_inv.unit_id, v_inv.id, CASE WHEN v_inv.direction = 'payable' THEN 'credit' ELSE 'debit' END,
46108
+ 'transfer', v_credit, v_credit, 'paid', current_date, current_date, 'credit', v_reason,
46109
+ jsonb_build_object('source', 'financial_cancel_invoice_items', 'credit_source', 'cancellation',
46110
+ 'cancelled_invoice_id', v_inv.id, 'cancelled_item_ids', to_jsonb(v_sel)),
46111
+ CASE WHEN p_idempotency_key IS NOT NULL THEN 'cancel-credit:' || p_idempotency_key END, auth.uid(),
46112
+ CASE WHEN v_inv.direction = 'payable' THEN v_person ELSE v_cancel_acc END,
46113
+ CASE WHEN v_inv.direction = 'payable' THEN v_cancel_acc ELSE v_person END)
46114
+ RETURNING id INTO v_mov;
46115
+ END IF;
46116
+
46117
+ INSERT INTO public.plg_financial_cancellations
46118
+ (tenant_id, unit_id, invoice_id, whole, item_ids, reason_id, reason, amount, credit_amount, credit_movement_id,
46119
+ stock, idempotency_key, created_by)
46120
+ VALUES (v_tenant, v_inv.unit_id, v_inv.id, v_whole, v_sel, p_reason_id, v_reason, v_amount, v_credit, v_mov,
46121
+ v_stock, p_idempotency_key, auth.uid())
46122
+ RETURNING id INTO v_log;
46123
+
46124
+ PERFORM public.plg_financial_audit(v_tenant,
46125
+ CASE WHEN v_whole THEN 'financial.invoice.cancel' ELSE 'financial.invoice.cancel_items' END,
46126
+ 'plg_financial_invoices', v_inv.id::text,
46127
+ jsonb_build_object('reason', v_reason, 'reason_id', p_reason_id, 'item_ids', to_jsonb(v_sel), 'amount', v_amount,
46128
+ 'credit_amount', v_credit, 'stock', v_stock, 'cancellation_id', v_log));
46129
+
46130
+ RETURN jsonb_build_object('cancellation_id', v_log, 'invoice_id', v_inv.id, 'whole', v_whole, 'amount', v_amount,
46131
+ 'credit_amount', v_credit, 'credit_movement_id', v_mov, 'stock', v_stock, 'replayed', false);
46132
+ END $$;
46133
+ REVOKE ALL ON FUNCTION public.financial_cancel_invoice_items(uuid, uuid[], uuid, text, jsonb, text) FROM PUBLIC;
46134
+ GRANT EXECUTE ON FUNCTION public.financial_cancel_invoice_items(uuid, uuid[], uuid, text, jsonb, text) TO authenticated, service_role;
46135
+
46136
+ -- O caminho antigo (cancelar em lote na lista) vira a conta inteira do novo,
46137
+ -- com o texto como detalhe e sem tocar no estoque.
46138
+ CREATE OR REPLACE FUNCTION public.financial_cancel_invoice(p_invoice_id uuid, p_reason text, p_idempotency_key text DEFAULT NULL::text) RETURNS jsonb
46139
+ LANGUAGE plpgsql SECURITY DEFINER
46140
+ SET search_path TO ''
46141
+ AS $$
46142
+ DECLARE
46143
+ v_tenant uuid := public.plg_financial_require_tenant();
46144
+ v_res jsonb;
46145
+ BEGIN
46146
+ IF nullif(btrim(coalesce(p_reason, '')), '') IS NULL THEN
46147
+ RAISE EXCEPTION 'financial_cancel_invoice: a reason is required' USING ERRCODE = '22023';
46148
+ END IF;
46149
+ IF EXISTS (SELECT 1 FROM public.plg_financial_invoices WHERE id = p_invoice_id AND tenant_id = v_tenant AND cancelled_at IS NOT NULL) THEN
46150
+ RETURN jsonb_build_object('invoice_id', p_invoice_id, 'status', 'cancelled', 'replayed', true);
46151
+ END IF;
46152
+ v_res := public.financial_cancel_invoice_items(p_invoice_id, NULL, NULL, p_reason, NULL, p_idempotency_key);
46153
+ RETURN v_res || jsonb_build_object('status', 'cancelled');
46154
+ END $$;
46155
+
46156
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
46157
+ -- 8. Pagar com o cr\xE9dito do fornecedor
46158
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
46159
+ -- O saldo da conta da pessoa N\xC3O serve: o porte do V1 lan\xE7a os pagamentos a
46160
+ -- fornecedor contra essa mesma conta (R$ 102 mil na Artorius em 09-10-2026), e
46161
+ -- lido como cr\xE9dito ele inventaria dinheiro. Cr\xE9dito de fornecedor \xE9 s\xF3 o que
46162
+ -- um cancelamento criou, menos o que j\xE1 se pagou com ele. As pernas do
46163
+ -- pagamento j\xE1 estavam certas; muda s\xF3 a confer\xEAncia. Remendo no corpo vivo,
46164
+ -- como a fiscal-br/015: reescrever 134 linhas de mem\xF3ria \xE9 como se perde regra.
46165
+
46166
+ CREATE OR REPLACE FUNCTION public.plg_financial_supplier_credit(p_tenant uuid, p_person uuid)
46167
+ RETURNS numeric LANGUAGE sql SECURITY DEFINER SET search_path TO '' AS $$
46168
+ WITH acc AS (SELECT public.plg_financial_person_account(p_tenant, p_person) AS id)
46169
+ SELECT round(
46170
+ coalesce((SELECT sum(m.amount) FROM public.plg_financial_movements m, acc
46171
+ WHERE m.tenant_id = p_tenant AND m.debit_account_id = acc.id AND m.status <> 'cancelled'
46172
+ AND m.reversed_by_id IS NULL AND m.metadata ->> 'credit_source' = 'cancellation'), 0)
46173
+ - coalesce((SELECT sum(m.amount) FROM public.plg_financial_movements m, acc
46174
+ WHERE m.tenant_id = p_tenant AND m.credit_account_id = acc.id AND m.status <> 'cancelled'
46175
+ AND m.reversed_by_id IS NULL AND m.movement_kind = 'payment' AND m.method = 'credit'
46176
+ AND m.direction = 'debit'), 0), 2)
46177
+ $$;
46178
+ REVOKE ALL ON FUNCTION public.plg_financial_supplier_credit(uuid, uuid) FROM PUBLIC;
46179
+ GRANT EXECUTE ON FUNCTION public.plg_financial_supplier_credit(uuid, uuid) TO service_role;
46180
+
46181
+ CREATE OR REPLACE FUNCTION public.financial_supplier_credit(p_person_id uuid)
46182
+ RETURNS numeric LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
46183
+ DECLARE v_tenant uuid := public.plg_financial_require_tenant();
46184
+ BEGIN
46185
+ PERFORM public.plg_financial_require_permission('financial.read');
46186
+ RETURN public.plg_financial_supplier_credit(v_tenant, p_person_id);
46187
+ END $$;
46188
+ REVOKE ALL ON FUNCTION public.financial_supplier_credit(uuid) FROM PUBLIC;
46189
+ GRANT EXECUTE ON FUNCTION public.financial_supplier_credit(uuid) TO authenticated, service_role;
46190
+
46191
+ DO $m$
46192
+ DECLARE
46193
+ v_def text;
46194
+ v_antes text := 'SELECT balance INTO v_credit FROM public.v_financial_account_balances WHERE account_id = v_holder;';
46195
+ v_depois text := 'SELECT CASE WHEN v_invoice.direction = ''payable'' THEN public.plg_financial_supplier_credit(v_tenant, v_invoice.counterparty_person_id) ELSE balance END INTO v_credit FROM public.v_financial_account_balances WHERE account_id = v_holder;';
46196
+ BEGIN
46197
+ SELECT pg_get_functiondef(p.oid) INTO v_def
46198
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
46199
+ WHERE n.nspname = 'public' AND p.proname = 'financial_receive_payment';
46200
+ IF v_def IS NULL OR position('plg_financial_supplier_credit' in v_def) > 0 THEN RETURN; END IF;
46201
+ IF position(v_antes in v_def) = 0 THEN
46202
+ RAISE EXCEPTION 'financial/120: financial_receive_payment n\xE3o \xE9 o esperado \u2014 confira \xE0 m\xE3o';
46203
+ END IF;
46204
+ EXECUTE replace(v_def, v_antes, v_depois);
46205
+ END
46206
+ $m$;
46207
+
46208
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
46209
+ -- 9. Juros, multa e desconto entram NA conta
46210
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
46211
+ -- No V1 o boleto pago com atraso ganha a linha \xABMulta/Juros\xBB (plano 5.1.3) e
46212
+ -- a parcela passa a valer o que o banco cobrou. A concilia\xE7\xE3o do V2 criava
46213
+ -- uma conta avulsa s\xF3 com os juros, sem fornecedor e sem caminho de volta.
46214
+
46215
+ CREATE OR REPLACE FUNCTION public.financial_add_installment_charge(
46216
+ p_installment_id uuid,
46217
+ p_kind text,
46218
+ p_amount numeric,
46219
+ p_chart_account_id uuid DEFAULT NULL,
46220
+ p_description text DEFAULT NULL,
46221
+ p_idempotency_key text DEFAULT NULL
46222
+ ) RETURNS jsonb
46223
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
46224
+ DECLARE
46225
+ v_tenant uuid := public.plg_financial_require_tenant();
46226
+ v_inst public.plg_financial_invoice_installments%ROWTYPE;
46227
+ v_inv public.plg_financial_invoices%ROWTYPE;
46228
+ v_amount numeric := round(coalesce(p_amount, 0), 2);
46229
+ v_signed numeric;
46230
+ v_paid numeric;
46231
+ v_item uuid;
46232
+ v_own boolean;
46233
+ v_label text;
46234
+ BEGIN
46235
+ IF p_kind NOT IN ('interest', 'fine', 'discount') THEN
46236
+ RAISE EXCEPTION 'financial_add_installment_charge: kind must be interest | fine | discount' USING ERRCODE = '22023';
46237
+ END IF;
46238
+ IF v_amount <= 0 THEN
46239
+ RAISE EXCEPTION 'financial_add_installment_charge: amount must be positive' USING ERRCODE = '22023';
46240
+ END IF;
46241
+ SELECT * INTO v_inst FROM public.plg_financial_invoice_installments WHERE id = p_installment_id AND tenant_id = v_tenant FOR UPDATE;
46242
+ IF NOT FOUND THEN
46243
+ RAISE EXCEPTION 'financial_add_installment_charge: installment % not found', p_installment_id USING ERRCODE = 'P0002';
46244
+ END IF;
46245
+ SELECT * INTO v_inv FROM public.plg_financial_invoices WHERE id = v_inst.invoice_id FOR UPDATE;
46246
+ PERFORM public.plg_financial_require_permission('financial.edit', v_inv.unit_id);
46247
+ IF v_inst.cancelled_at IS NOT NULL OR v_inv.cancelled_at IS NOT NULL THEN
46248
+ RAISE EXCEPTION 'financial_add_installment_charge: installment is cancelled' USING ERRCODE = '55000';
46249
+ END IF;
46250
+ IF p_idempotency_key IS NOT NULL THEN
46251
+ SELECT i.id INTO v_item FROM public.items i
46252
+ WHERE i.tenant_id = v_tenant AND i.metadata ->> 'charge_idempotency_key' = p_idempotency_key;
46253
+ IF v_item IS NOT NULL THEN
46254
+ RETURN jsonb_build_object('item_id', v_item, 'installment_id', v_inst.id, 'invoice_id', v_inv.id, 'replayed', true);
46255
+ END IF;
46256
+ END IF;
46257
+
46258
+ v_signed := CASE WHEN p_kind = 'discount' THEN -v_amount ELSE v_amount END;
46259
+ v_paid := public.plg_financial_installment_paid(v_inst.id);
46260
+ IF v_inst.amount + v_signed < v_paid THEN
46261
+ RAISE EXCEPTION 'financial_add_installment_charge: the discount takes installment % below what was already paid (%)', v_inst.number, v_paid
46262
+ USING ERRCODE = '23514';
46263
+ END IF;
46264
+ v_label := coalesce(nullif(btrim(coalesce(p_description, '')), ''),
46265
+ CASE p_kind WHEN 'interest' THEN 'Juros por atraso' WHEN 'fine' THEN 'Multa por atraso' ELSE 'Desconto obtido' END);
46266
+
46267
+ PERFORM set_config('financial.amend', 'on', true);
46268
+
46269
+ -- A linha mora onde as outras moram: conta que veio de um pedido (o V1 todo)
46270
+ -- lista as linhas DO PEDIDO enquanto n\xE3o tiver nenhuma pr\xF3pria \u2014 p\xF4r a
46271
+ -- primeira na conta faria as outras sumirem da tela (v_financial_invoice_lines).
46272
+ v_own := EXISTS (SELECT 1 FROM public.items WHERE invoice_id = v_inv.id) OR v_inv.order_id IS NULL;
46273
+ INSERT INTO public.items (tenant_id, unit_id, invoice_id, order_id, name, description, quantity, unit_price, total,
46274
+ sort_order, chart_account_id, metadata)
46275
+ VALUES (v_tenant, v_inv.unit_id,
46276
+ CASE WHEN v_own THEN v_inv.id END, CASE WHEN v_own THEN NULL ELSE v_inv.order_id END,
46277
+ v_label, v_label, 1, v_signed, v_signed,
46278
+ 1000 + coalesce((SELECT count(*) FROM public.v_financial_invoice_lines l WHERE l.invoice_id = v_inv.id), 0),
46279
+ p_chart_account_id,
46280
+ jsonb_strip_nulls(jsonb_build_object('itemKind', 'other', 'charge', p_kind, 'installment_id', v_inst.id,
46281
+ 'source', 'financial_add_installment_charge',
46282
+ 'charge_idempotency_key', p_idempotency_key)))
46283
+ RETURNING id INTO v_item;
46284
+
46285
+ UPDATE public.plg_financial_invoice_installments
46286
+ SET amount = amount + v_signed,
46287
+ metadata = metadata || jsonb_build_object('v2_decision', jsonb_build_object('amended', true))
46288
+ WHERE id = v_inst.id;
46289
+ UPDATE public.plg_financial_invoices
46290
+ SET total = total + v_signed,
46291
+ metadata = metadata || jsonb_build_object('v2_decision', jsonb_build_object('amended', true, 'at', now()))
46292
+ WHERE id = v_inv.id;
46293
+ PERFORM public.plg_financial_sync_mirror(v_inst.id);
46294
+
46295
+ PERFORM public.plg_financial_audit(v_tenant, 'financial.invoice.charge', 'plg_financial_invoices', v_inv.id::text,
46296
+ jsonb_build_object('installment_id', v_inst.id, 'kind', p_kind, 'amount', v_signed, 'item_id', v_item,
46297
+ 'chart_account_id', p_chart_account_id));
46298
+ RETURN jsonb_build_object('item_id', v_item, 'installment_id', v_inst.id, 'invoice_id', v_inv.id,
46299
+ 'amount', v_signed, 'installment_amount', v_inst.amount + v_signed, 'replayed', false);
46300
+ END $$;
46301
+ REVOKE ALL ON FUNCTION public.financial_add_installment_charge(uuid, text, numeric, uuid, text, text) FROM PUBLIC;
46302
+ GRANT EXECUTE ON FUNCTION public.financial_add_installment_charge(uuid, text, numeric, uuid, text, text) TO authenticated, service_role;
46303
+
46304
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
46305
+ -- 10. A sincronia do V1 respeita o V2 \u2014 e para de dobrar parcela
46306
+ -- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
46307
+ -- Duas mudan\xE7as no corpo vivo (que difere do arquivo 071/079):
46308
+ --
46309
+ -- a) parcela que o V2 cancelou ou emendou (\`v2_decision\`) n\xE3o volta a valer
46310
+ -- nem a ter o valor antigo quando o V1 regrava a linha;
46311
+ -- b) o V1 apaga e recria a parcela ao salvar a conta. Em conviv\xEAncia a
46312
+ -- exclus\xE3o n\xE3o atravessa, e a recriada ca\xEDa no \`unique_violation\` e
46313
+ -- ENTRAVA COMO N\xBA+1 \u2014 PAG-000330 com 5.656,40 + 5.656,48 para um aluguel
46314
+ -- de 5.656,48. Agora, se somar a nova estouraria o total da conta, ela \xE9
46315
+ -- a substituta: a antiga (migrada, sem baixa) sai cancelada e a nova
46316
+ -- herda o n\xFAmero. Duas parcelas vivas de mesmo n\xFAmero que CABEM no total
46317
+ -- (a fatura #1869) continuam entrando como antes.
46318
+
46319
+ CREATE OR REPLACE FUNCTION migration._fin_installment_from_bill(p_tenant uuid, p_id uuid, p_invoice uuid, p_unit uuid, p_number integer, p_due date, p_amount numeric, p_cancelled boolean, p_sync boolean)
46320
+ RETURNS void
46321
+ LANGUAGE plpgsql
46322
+ SECURITY DEFINER
46323
+ SET search_path TO ''
46324
+ AS $function$
46325
+ DECLARE
46326
+ v_number integer := greatest(coalesce(p_number, 1), 1);
46327
+ v_cancel timestamptz := CASE WHEN coalesce(p_cancelled, false)
46328
+ THEN coalesce(p_due, current_date)::timestamptz END;
46329
+ v_old record;
46330
+ v_total numeric;
46331
+ v_live numeric;
46332
+ BEGIN
46333
+ IF p_invoice IS NULL THEN RETURN; END IF;
46334
+
46335
+ DELETE FROM public.plg_financial_invoice_installments i
46336
+ WHERE i.invoice_id = p_invoice
46337
+ AND i.id <> p_id
46338
+ AND NOT (i.metadata ? 'migration')
46339
+ AND NOT EXISTS (SELECT 1 FROM public.plg_financial_allocations a WHERE a.installment_id = i.id)
46340
+ AND NOT EXISTS (SELECT 1 FROM public.plg_financial_movements m
46341
+ WHERE m.financial_invoice_installment_id = i.id);
46342
+
46343
+ BEGIN
46344
+ INSERT INTO public.plg_financial_invoice_installments
46345
+ (id, tenant_id, unit_id, invoice_id, number, due_date, amount, cancelled_at, metadata)
46346
+ VALUES (p_id, p_tenant, p_unit, p_invoice, v_number, p_due,
46347
+ greatest(coalesce(p_amount, 0), 0), v_cancel,
46348
+ jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements')))
46349
+ ON CONFLICT (id) DO UPDATE SET
46350
+ number = EXCLUDED.number,
46351
+ due_date = EXCLUDED.due_date,
46352
+ amount = CASE WHEN public.plg_financial_invoice_installments.metadata ? 'v2_decision'
46353
+ THEN public.plg_financial_invoice_installments.amount ELSE EXCLUDED.amount END,
46354
+ cancelled_at = CASE WHEN public.plg_financial_invoice_installments.metadata ? 'v2_decision'
46355
+ THEN public.plg_financial_invoice_installments.cancelled_at ELSE EXCLUDED.cancelled_at END,
46356
+ unit_id = coalesce(public.plg_financial_invoice_installments.unit_id, EXCLUDED.unit_id),
46357
+ metadata = public.plg_financial_invoice_installments.metadata || EXCLUDED.metadata;
46358
+ EXCEPTION
46359
+ WHEN unique_violation THEN
46360
+ SELECT i.id, i.amount INTO v_old
46361
+ FROM public.plg_financial_invoice_installments i
46362
+ WHERE i.invoice_id = p_invoice AND i.number = v_number AND i.id <> p_id
46363
+ AND i.cancelled_at IS NULL
46364
+ AND i.metadata ? 'migration' AND NOT (i.metadata ? 'v2_decision')
46365
+ AND NOT EXISTS (SELECT 1 FROM public.plg_financial_allocations a WHERE a.installment_id = i.id);
46366
+ SELECT f.total INTO v_total FROM public.plg_financial_invoices f WHERE f.id = p_invoice;
46367
+ SELECT coalesce(sum(i.amount), 0) INTO v_live FROM public.plg_financial_invoice_installments i
46368
+ WHERE i.invoice_id = p_invoice AND i.cancelled_at IS NULL;
46369
+
46370
+ IF v_old.id IS NOT NULL AND v_cancel IS NULL
46371
+ AND v_live + greatest(coalesce(p_amount, 0), 0) > coalesce(v_total, 0) + 0.01 THEN
46372
+ -- A substituta: a antiga sai do n\xFAmero (cancelada) e a nova entra nele.
46373
+ UPDATE public.plg_financial_invoice_installments
46374
+ SET cancelled_at = now(),
46375
+ number = (SELECT coalesce(max(x.number), 0) + 1 FROM public.plg_financial_invoice_installments x WHERE x.invoice_id = p_invoice),
46376
+ metadata = metadata || jsonb_build_object('superseded_by', p_id)
46377
+ WHERE id = v_old.id;
46378
+ INSERT INTO public.plg_financial_invoice_installments
46379
+ (id, tenant_id, unit_id, invoice_id, number, due_date, amount, cancelled_at, metadata)
46380
+ VALUES (p_id, p_tenant, p_unit, p_invoice, v_number, p_due, greatest(coalesce(p_amount, 0), 0), NULL,
46381
+ jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements', 'supersedes', v_old.id)))
46382
+ ON CONFLICT (id) DO NOTHING;
46383
+ PERFORM public.plg_financial_sync_mirror(v_old.id);
46384
+ ELSE
46385
+ INSERT INTO public.plg_financial_invoice_installments
46386
+ (id, tenant_id, unit_id, invoice_id, number, due_date, amount, cancelled_at, metadata)
46387
+ SELECT p_id, p_tenant, p_unit, p_invoice, coalesce(max(i.number), 0) + 1, p_due,
46388
+ greatest(coalesce(p_amount, 0), 0), v_cancel,
46389
+ jsonb_build_object('migration', jsonb_build_object('from', 'financial_movements', 'renumbered', true))
46390
+ FROM public.plg_financial_invoice_installments i
46391
+ WHERE i.invoice_id = p_invoice
46392
+ ON CONFLICT (id) DO NOTHING;
46393
+ END IF;
46394
+ END;
46395
+
46396
+ IF p_sync THEN
46397
+ PERFORM public.plg_financial_sync_mirror(p_id);
46398
+ END IF;
46399
+ END $function$;
46400
+ `;
46401
+ var MIGRATION_121_O_MOTIVO_E_DO_ESTABELECIMENTO = `-- O terceiro motivo padr\xE3o \xE9 \xABSolicitado pelo estabelecimento\xBB, n\xE3o \xABpelo
46402
+ -- profissional\xBB (decis\xE3o do founder, 09-10-2026): numa conta a pagar quem pede
46403
+ -- o cancelamento \xE9 a casa, e o V1 herdou o r\xF3tulo da agenda de servi\xE7os.
46404
+ --
46405
+ -- Renomeia o semeado pela 120 s\xF3 onde ningu\xE9m o usou \u2014 um motivo j\xE1 gravado num
46406
+ -- cancelamento continua dizendo o que disse. Idempotente.
46407
+
46408
+ UPDATE public.plg_financial_cancellation_reasons r
46409
+ SET name = 'Solicitado pelo estabelecimento'
46410
+ WHERE r.name = 'Solicitado pelo profissional'
46411
+ AND NOT EXISTS (SELECT 1 FROM public.plg_financial_cancellations c WHERE c.reason_id = r.id)
46412
+ AND NOT EXISTS (SELECT 1 FROM public.plg_financial_cancellation_reasons x
46413
+ WHERE x.tenant_id = r.tenant_id AND lower(x.name) = 'solicitado pelo estabelecimento');
46414
+
46415
+ CREATE OR REPLACE FUNCTION public.financial_cancellation_reasons()
46416
+ RETURNS SETOF public.plg_financial_cancellation_reasons
46417
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO '' AS $$
46418
+ DECLARE v_tenant uuid := public.plg_financial_require_tenant();
46419
+ BEGIN
46420
+ PERFORM public.plg_financial_require_permission('financial.read');
46421
+ IF NOT EXISTS (SELECT 1 FROM public.plg_financial_cancellation_reasons r WHERE r.tenant_id = v_tenant) THEN
46422
+ INSERT INTO public.plg_financial_cancellation_reasons (tenant_id, name)
46423
+ SELECT v_tenant, n FROM unnest(ARRAY['Outros', 'Solicitado pelo cliente', 'Solicitado pelo estabelecimento']) n
46424
+ ON CONFLICT DO NOTHING;
46425
+ END IF;
46426
+ RETURN QUERY SELECT * FROM public.plg_financial_cancellation_reasons r
46427
+ WHERE r.tenant_id = v_tenant AND r.is_active ORDER BY r.name;
46428
+ END $$;
46429
+ `;
46430
+ var MIGRATION_122_O_CANCELAMENTO_FECHA_AS_PORTAS_QUE_ABRIU = `-- ============================================================================
46431
+ -- 122 \u2014 cancelling closes the doors 120 left open
46432
+ --
46433
+ -- 120 (cancel by item, with reason, stock and supplier credit) shipped three
46434
+ -- regressions the gates caught on main and that were live on the cluster:
46435
+ --
46436
+ -- 1. \`v_financial_invoice_lines\` was recreated WITHOUT \`security_invoker\`. A
46437
+ -- view without it runs as its owner and skips RLS, so every signed-in user
46438
+ -- could read every tenant's invoice lines. Every earlier definition of this
46439
+ -- view (010 \u2026 114) was security_invoker; this puts it back.
46440
+ --
46441
+ -- 2. \`plg_financial_supplier_credit(p_tenant, p_person)\` takes the tenant as an
46442
+ -- argument and was revoked only FROM PUBLIC. The pools' default privileges
46443
+ -- grant EXECUTE to \`authenticated\` on every new function, so any user could
46444
+ -- ask for any tenant's supplier credit. Only the definer wrappers
46445
+ -- (\`financial_supplier_credit\`, the payment check) call it, and they run as
46446
+ -- the owner, so closing it to clients changes nothing for them.
46447
+ --
46448
+ -- 3. Three money RPCs accept \`p_idempotency_key\` but did not claim it, so two
46449
+ -- concurrent calls with the same key could both pass the replay lookup and
46450
+ -- both write (#142). They now take the same advisory lock every other money
46451
+ -- RPC takes, right before that lookup. The bodies are patched in place, as
46452
+ -- 120 itself patched the payment check: copying 130 lines by hand is how a
46453
+ -- rule gets lost.
46454
+ --
46455
+ -- 1 and 2 were hot-fixed on klvfxzreepavcpyjiwla on 2026-10-09 with the same
46456
+ -- statements; this file is the record, and it is idempotent.
46457
+ -- ============================================================================
46458
+
46459
+ -- \u2500\u2500 1. the view reads with the caller's rights again \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
46460
+ ALTER VIEW public.v_financial_invoice_lines SET (security_invoker = true);
46461
+
46462
+ -- \u2500\u2500 2. the tenant-taking helper is not a client door \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
46463
+ REVOKE EXECUTE ON FUNCTION public.plg_financial_supplier_credit(uuid, uuid) FROM PUBLIC, anon, authenticated;
46464
+ GRANT EXECUTE ON FUNCTION public.plg_financial_supplier_credit(uuid, uuid) TO service_role;
46465
+
46466
+ -- \u2500\u2500 3. an idempotency key is claimed before it is looked up \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
46467
+ DO $patch$
46468
+ DECLARE
46469
+ v_fn regprocedure;
46470
+ v_def text;
46471
+ v_anchor text;
46472
+ v_claim constant text := 'PERFORM public.plg_financial_claim_idempotency(v_tenant, p_idempotency_key);';
46473
+ BEGIN
46474
+ FOR v_fn, v_anchor IN
46475
+ SELECT f::regprocedure, a FROM (VALUES
46476
+ -- the replay lookup is the first thing that reads the key
46477
+ ('public.financial_cancel_invoice_items(uuid, uuid[], uuid, text, jsonb, text)', ' IF p_idempotency_key IS NOT NULL THEN'),
46478
+ ('public.financial_add_installment_charge(uuid, text, numeric, uuid, text, text)', ' IF p_idempotency_key IS NOT NULL THEN'),
46479
+ -- this one replays on "already cancelled" before it delegates, so the
46480
+ -- claim goes ahead of that check; the delegate re-takes the same lock,
46481
+ -- which a transaction-scoped advisory lock allows
46482
+ ('public.financial_cancel_invoice(uuid, text, text)', ' IF EXISTS (SELECT 1 FROM public.plg_financial_invoices WHERE id = p_invoice_id')
46483
+ ) AS t(f, a)
46484
+ LOOP
46485
+ v_def := pg_get_functiondef(v_fn);
46486
+ IF position('plg_financial_claim_idempotency' in v_def) > 0 THEN
46487
+ CONTINUE; -- already claims (a replay, or a later rewrite)
46488
+ END IF;
46489
+ IF position(v_anchor in v_def) = 0 THEN
46490
+ RAISE EXCEPTION '122: % no longer has the line the claim goes before (%); patch it by hand', v_fn, v_anchor;
46491
+ END IF;
46492
+ -- first occurrence only: the anchor is the replay lookup, not every later use of the key
46493
+ EXECUTE overlay(v_def PLACING ' ' || v_claim || E'\\n' || v_anchor
46494
+ FROM position(v_anchor in v_def) FOR length(v_anchor));
46495
+ END LOOP;
46496
+ END $patch$;
46497
+ `;
45330
46498
  var MIGRATIONS = [
45331
46499
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
45332
46500
  { id: "001_the_comanda_becomes_a_receivable_when_it_closes", sql: MIGRATION_001_THE_COMANDA_BECOMES_A_RECEIVABLE_WHEN_IT_CLOSES },
@@ -45447,7 +46615,10 @@ var MIGRATIONS = [
45447
46615
  { id: "116_um_estorno_nao_se_estorna", sql: MIGRATION_116_UM_ESTORNO_NAO_SE_ESTORNA },
45448
46616
  { id: "117_copying_lines_is_an_internal_step", sql: MIGRATION_117_COPYING_LINES_IS_AN_INTERNAL_STEP },
45449
46617
  { id: "118_gift_card_found_by_exact_code", sql: MIGRATION_118_GIFT_CARD_FOUND_BY_EXACT_CODE },
45450
- { id: "119_an_unpaid_legacy_bill_can_be_rescheduled", sql: MIGRATION_119_AN_UNPAID_LEGACY_BILL_CAN_BE_RESCHEDULED }
46618
+ { id: "119_an_unpaid_legacy_bill_can_be_rescheduled", sql: MIGRATION_119_AN_UNPAID_LEGACY_BILL_CAN_BE_RESCHEDULED },
46619
+ { id: "120_cancelar_e_por_item_com_motivo_e_destino", sql: MIGRATION_120_CANCELAR_E_POR_ITEM_COM_MOTIVO_E_DESTINO },
46620
+ { id: "121_o_motivo_e_do_estabelecimento", sql: MIGRATION_121_O_MOTIVO_E_DO_ESTABELECIMENTO },
46621
+ { id: "122_o_cancelamento_fecha_as_portas_que_abriu", sql: MIGRATION_122_O_CANCELAMENTO_FECHA_AS_PORTAS_QUE_ABRIU }
45451
46622
  ];
45452
46623
  var STANDALONE_CONFIG = resolveConfig();
45453
46624
  registerTranslations(financialLocales);
@@ -45457,8 +46628,8 @@ function ReceivePaymentModal({
45457
46628
  onClose,
45458
46629
  onPaid
45459
46630
  }) {
45460
- const provider = React25__default.useMemo(() => createSafeFinancialProvider(), []);
45461
- const store6 = React25__default.useMemo(() => createFinancialStore(provider), [provider]);
46631
+ const provider = React26__default.useMemo(() => createSafeFinancialProvider(), []);
46632
+ const store6 = React26__default.useMemo(() => createFinancialStore(provider), [provider]);
45462
46633
  return /* @__PURE__ */ jsx(FinancialContextProvider, { config: STANDALONE_CONFIG, provider, store: store6, children: /* @__PURE__ */ jsx(
45463
46634
  PaymentModal,
45464
46635
  {
@@ -45476,7 +46647,7 @@ function createFinancialSettingsPlugin(options) {
45476
46647
  ...financialRegistries.filter((r) => RECEIVING_REGISTRIES.includes(r.id)),
45477
46648
  ...options?.extraRegistries ?? []
45478
46649
  ];
45479
- const SettingsTab = () => React25__default.createElement(PluginSettingsPanel, {
46650
+ const SettingsTab = () => React26__default.createElement(PluginSettingsPanel, {
45480
46651
  title: "Pagamentos",
45481
46652
  registries,
45482
46653
  hostPluginId: "financial",
@@ -45533,27 +46704,27 @@ function ClientAccountPanel({
45533
46704
  currency = BRL
45534
46705
  }) {
45535
46706
  const provider = useFinancialProvider();
45536
- const [account, setAccount] = React25__default.useState(null);
45537
- const [busy, setBusy] = React25__default.useState(false);
45538
- const [viewInvoiceId, setViewInvoiceId] = React25__default.useState(null);
45539
- const [comprando, setComprando] = React25__default.useState(false);
45540
- const [buscaServico, setBuscaServico] = React25__default.useState("");
45541
- const [catalogo, setCatalogo] = React25__default.useState([]);
45542
- const [escolhidos, setEscolhidos] = React25__default.useState([]);
45543
- const [comprandoBusy, setComprandoBusy] = React25__default.useState(false);
45544
- const [erroCompra, setErroCompra] = React25__default.useState(null);
45545
- const load = React25__default.useCallback(async () => {
46707
+ const [account, setAccount] = React26__default.useState(null);
46708
+ const [busy, setBusy] = React26__default.useState(false);
46709
+ const [viewInvoiceId, setViewInvoiceId] = React26__default.useState(null);
46710
+ const [comprando, setComprando] = React26__default.useState(false);
46711
+ const [buscaServico, setBuscaServico] = React26__default.useState("");
46712
+ const [catalogo, setCatalogo] = React26__default.useState([]);
46713
+ const [escolhidos, setEscolhidos] = React26__default.useState([]);
46714
+ const [comprandoBusy, setComprandoBusy] = React26__default.useState(false);
46715
+ const [erroCompra, setErroCompra] = React26__default.useState(null);
46716
+ const load = React26__default.useCallback(async () => {
45546
46717
  if (!personId) return;
45547
46718
  setAccount(await provider.getClientAccount(personId));
45548
46719
  }, [personId, provider]);
45549
- React25__default.useEffect(() => {
46720
+ React26__default.useEffect(() => {
45550
46721
  void load();
45551
46722
  }, [load]);
45552
46723
  const viewInvoice = account?.invoices.find((i) => i.id === viewInvoiceId) ?? null;
45553
46724
  const viewEditable = invoiceOpensForEditing(viewInvoice?.status);
45554
46725
  const forThis = account?.invoices.filter((i) => orderId && i.orderId === orderId) ?? [];
45555
46726
  const hasForThis = forThis.length > 0;
45556
- React25__default.useEffect(() => {
46727
+ React26__default.useEffect(() => {
45557
46728
  if (!comprando) return;
45558
46729
  let vivo = true;
45559
46730
  const t2 = setTimeout(async () => {
@@ -45828,8 +46999,8 @@ function ClientAccountPanel({
45828
46999
  ] });
45829
47000
  }
45830
47001
  function ClientAccountInline(props) {
45831
- const provider = React25__default.useMemo(() => createSafeFinancialProvider(), []);
45832
- const store6 = React25__default.useMemo(() => createFinancialStore(provider), [provider]);
47002
+ const provider = React26__default.useMemo(() => createSafeFinancialProvider(), []);
47003
+ const store6 = React26__default.useMemo(() => createFinancialStore(provider), [provider]);
45833
47004
  return /* @__PURE__ */ jsx(FinancialContextProvider, { config: STANDALONE_CONFIG, provider, store: store6, children: /* @__PURE__ */ jsx(ClientAccountPanel, { ...props }) });
45834
47005
  }
45835
47006
  function ClientAccountModal({
@@ -45877,11 +47048,11 @@ function createFinancialPlugin(options) {
45877
47048
  const settingsRegistries = registries.filter((r) => r.id !== "bank-accounts");
45878
47049
  const AccountsRegistryPage = createCrudPage(financialAccountEntity, { feature: "financial" });
45879
47050
  AccountsRegistryPage.displayName = "FinancialAccountsRegistryPage";
45880
- const PersonStatementBound = (props) => React25__default.createElement(PersonStatementWidget, { person: props.item, provider, config });
47051
+ const PersonStatementBound = (props) => React26__default.createElement(PersonStatementWidget, { person: props.item, provider, config });
45881
47052
  PersonStatementBound.displayName = "FinancialPersonStatement";
45882
- const SupplierFinancialSettingsBound = (props) => React25__default.createElement(SupplierFinancialSettings, { person: props.item, provider });
47053
+ const SupplierFinancialSettingsBound = (props) => React26__default.createElement(SupplierFinancialSettings, { person: props.item, provider });
45883
47054
  SupplierFinancialSettingsBound.displayName = "FinancialSupplierSettings";
45884
- const PageComponent = () => React25__default.createElement(FinancialPage, { config, provider, store: store6, registries });
47055
+ const PageComponent = () => React26__default.createElement(FinancialPage, { config, provider, store: store6, registries });
45885
47056
  const AgentMount = createFinancialAgentMount({ config, provider });
45886
47057
  return {
45887
47058
  id: "financial",
@@ -45939,7 +47110,7 @@ function createFinancialPlugin(options) {
45939
47110
  // tem — lá é um ícone no cabeçalho, de qualquer tela.
45940
47111
  headerControls: config.modules.cashRegisters ? [{
45941
47112
  id: "financial.cash-session",
45942
- component: (() => React25__default.createElement(CashSessionHeaderControl, { config, provider, store: store6 })),
47113
+ component: (() => React26__default.createElement(CashSessionHeaderControl, { config, provider, store: store6 })),
45943
47114
  order: 90,
45944
47115
  permission: { feature: "financial", action: "read" }
45945
47116
  }] : [],
@@ -46170,9 +47341,9 @@ function createFinancialPlugin(options) {
46170
47341
  // lado, e a corrida de "Plugins" não é partida ao meio.
46171
47342
  group: "Financeiro",
46172
47343
  component: (() => {
46173
- const FinancialSettingsTab = () => React25__default.createElement(PluginSettingsPanel, {
47344
+ const FinancialSettingsTab = () => React26__default.createElement(PluginSettingsPanel, {
46174
47345
  title: "Financial Settings",
46175
- generalSettings: React25__default.createElement(FinancialGeneralSettings, { config }),
47346
+ generalSettings: React26__default.createElement(FinancialGeneralSettings, { config }),
46176
47347
  registries: settingsRegistries,
46177
47348
  hostPluginId: "financial",
46178
47349
  routeBase: "/settings/financial"
@@ -46197,7 +47368,7 @@ function createFinancialPlugin(options) {
46197
47368
  icon: "HandCoins",
46198
47369
  group: "Financeiro",
46199
47370
  component: (() => {
46200
- const CommissionRulesTab = () => React25__default.createElement(CommissionRulesView, { provider });
47371
+ const CommissionRulesTab = () => React26__default.createElement(CommissionRulesView, { provider });
46201
47372
  CommissionRulesTab.displayName = "CommissionRulesTab";
46202
47373
  return CommissionRulesTab;
46203
47374
  })(),