@fayz-ai/plugin-financial 0.23.0 → 0.23.1

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
@@ -8471,6 +8471,50 @@ function CashClosingPanel({
8471
8471
  )
8472
8472
  ] });
8473
8473
  }
8474
+ function dayKeyOf(iso) {
8475
+ if (!iso) return "";
8476
+ const d = new Date(iso);
8477
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
8478
+ }
8479
+ function groupByClosingDay(rows) {
8480
+ const days = [];
8481
+ const sorted = [...rows].sort((a, b) => (b.closedAt ?? "").localeCompare(a.closedAt ?? ""));
8482
+ for (const r of sorted) {
8483
+ const key = dayKeyOf(r.closedAt);
8484
+ const last = days[days.length - 1];
8485
+ if (last && last.key === key) last.rows.push(r);
8486
+ else days.push({ key, rows: [r] });
8487
+ }
8488
+ return days;
8489
+ }
8490
+ function DayHeader({ dayKey, count }) {
8491
+ const t2 = useTranslation();
8492
+ let label = "\u2014";
8493
+ if (dayKey === dayKeyOf((/* @__PURE__ */ new Date()).toISOString())) label = t2("financial.cash.reviewToday");
8494
+ else if (dayKey === dayKeyOf(new Date(Date.now() - 864e5).toISOString())) label = t2("financial.cash.reviewYesterday");
8495
+ else if (dayKey) {
8496
+ const [y, m, d] = dayKey.split("-").map(Number);
8497
+ label = new Date(y, m - 1, d).toLocaleDateString(void 0, { weekday: "long", day: "2-digit", month: "2-digit" });
8498
+ }
8499
+ return (
8500
+ // Stays on screen while its day scrolls, stopping under the page's tab band
8501
+ // like a list's column header. Opaque: rows pass underneath it.
8502
+ /* @__PURE__ */ jsxs(
8503
+ "div",
8504
+ {
8505
+ className: "sticky z-10 flex items-center gap-2 border-b bg-muted px-4 py-1.5 text-xs font-medium text-muted-foreground",
8506
+ style: { top: "var(--fz-page-band-h, 0px)" },
8507
+ children: [
8508
+ /* @__PURE__ */ jsx("span", { className: "first-letter:uppercase", children: label }),
8509
+ /* @__PURE__ */ jsx("span", { className: "rounded-full bg-background px-1.5 text-[10px] tabular-nums", children: count })
8510
+ ]
8511
+ }
8512
+ )
8513
+ );
8514
+ }
8515
+ var HOUR_MINUTE = { hour: "2-digit", minute: "2-digit" };
8516
+ var PANEL_REVIEW_LIMIT = 3;
8517
+ var REVIEW_TAB_QUERY = "tab=review";
8474
8518
  function ReviewQueue({
8475
8519
  variant,
8476
8520
  rows,
@@ -8484,6 +8528,11 @@ function ReviewQueue({
8484
8528
  const me = user?.id ?? null;
8485
8529
  const [reviewing, setReviewing] = useState(null);
8486
8530
  const pendentes = (rows ?? []).filter((r) => r.reviewStatus === "pending");
8531
+ const setCashSessionModalOpen = useFinancialStore((s) => s.setCashSessionModalOpen);
8532
+ const compact = variant === "panel";
8533
+ const shown = compact ? [...pendentes].sort((a, b) => Math.abs(b.difference) - Math.abs(a.difference) || (a.closedAt ?? "").localeCompare(b.closedAt ?? "")).slice(0, PANEL_REVIEW_LIMIT) : pendentes;
8534
+ const hidden = pendentes.length - shown.length;
8535
+ const days = compact ? [] : groupByClosingDay(pendentes);
8487
8536
  const sectionClass = variant === "panel" ? "" : "rounded-card border bg-card shadow-sm";
8488
8537
  return /* @__PURE__ */ jsxs("div", { className: sectionClass, children: [
8489
8538
  showHeader && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b px-4 py-3", children: [
@@ -8491,39 +8540,68 @@ function ReviewQueue({
8491
8540
  /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold", children: t2("financial.cash.reviewQueue") }),
8492
8541
  pendentes.length > 0 && /* @__PURE__ */ jsx("span", { className: "rounded-full bg-info/10 px-2 py-0.5 text-[10px] font-semibold text-info", children: pendentes.length })
8493
8542
  ] }),
8494
- showHeader && /* @__PURE__ */ jsx("p", { className: "px-4 pt-3 text-[11px] text-muted-foreground", children: "Quem abre e fecha o caixa n\xE3o \xE9 quem confere. Um fechamento fica aqui at\xE9 algu\xE9m da ger\xEAncia ou do financeiro aprovar." }),
8543
+ showHeader && !compact && /* @__PURE__ */ jsx("p", { className: "px-4 pt-3 text-[11px] text-muted-foreground", children: "Quem abre e fecha o caixa n\xE3o \xE9 quem confere. Um fechamento fica aqui at\xE9 algu\xE9m da ger\xEAncia ou do financeiro aprovar." }),
8495
8544
  error && /* @__PURE__ */ jsx("p", { className: "px-4 pt-2 text-xs text-destructive", children: error }),
8496
8545
  rows === null ? /* @__PURE__ */ jsx("div", { className: "space-y-2 p-4", children: [0, 1].map((i) => /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
8497
8546
  /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-48" }),
8498
8547
  /* @__PURE__ */ jsx(Skeleton, { className: "h-7 w-32" })
8499
- ] }, i)) }) : pendentes.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-4 py-6 text-center text-sm text-muted-foreground", children: t2("financial.cash.reviewQueueEmpty") }) : /* @__PURE__ */ jsx("div", { className: "divide-y", children: pendentes.map((r) => {
8500
- const fechei = !!me && r.closedByUserId === me;
8501
- return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 px-4 py-3", children: [
8502
- /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
8503
- /* @__PURE__ */ jsxs("p", { className: "text-sm font-medium text-foreground", children: [
8548
+ ] }, i)) }) : pendentes.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-4 py-6 text-center text-sm text-muted-foreground", children: t2("financial.cash.reviewQueueEmpty") }) : /* @__PURE__ */ jsxs("div", { className: "divide-y", children: [
8549
+ (compact ? [{ key: "", rows: shown }] : days).map((day3) => /* @__PURE__ */ jsxs(React29__default.Fragment, { children: [
8550
+ !compact && /* @__PURE__ */ jsx(DayHeader, { dayKey: day3.key, count: day3.rows.length }),
8551
+ day3.rows.map((r) => {
8552
+ const fechei = !!me && r.closedByUserId === me;
8553
+ const amount = /* @__PURE__ */ jsxs(Fragment, { children: [
8504
8554
  formatCurrency(r.closingBalance, currency),
8505
8555
  r.difference !== 0 && /* @__PURE__ */ jsxs("span", { className: `ml-2 text-xs tabular-nums ${r.difference > 0 ? "text-success" : "text-destructive"}`, children: [
8506
8556
  r.difference > 0 ? "+" : "",
8507
8557
  formatCurrency(r.difference, currency)
8508
8558
  ] })
8509
- ] }),
8510
- /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
8511
- t2("financial.cash.closedBy"),
8559
+ ] });
8560
+ const action = fechei ? (
8561
+ // Estado que impede não oferece a ação — e diz por quê.
8562
+ /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground", children: t2("financial.cash.cannotReviewOwn") })
8563
+ ) : /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", className: "shrink-0", onClick: () => setReviewing(r.sessionId), children: [
8564
+ /* @__PURE__ */ jsx(ClipboardCheck, { className: "h-3.5 w-3.5" }),
8512
8565
  " ",
8513
- r.closedByName ?? "\u2014",
8514
- r.closedAt ? ` \xB7 ${new Date(r.closedAt).toLocaleString()}` : ""
8515
- ] })
8516
- ] }),
8517
- fechei ? (
8518
- // Estado que impede não oferece a ação — e diz por quê.
8519
- /* @__PURE__ */ jsx("p", { className: "text-[11px] text-muted-foreground", children: t2("financial.cash.cannotReviewOwn") })
8520
- ) : /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setReviewing(r.sessionId), children: [
8521
- /* @__PURE__ */ jsx(ClipboardCheck, { className: "h-3.5 w-3.5" }),
8522
- " ",
8523
- t2("financial.cash.review")
8524
- ] })
8525
- ] }, r.sessionId);
8526
- }) }),
8566
+ t2("financial.cash.review")
8567
+ ] });
8568
+ if (compact) {
8569
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-2", children: [
8570
+ /* @__PURE__ */ jsx("p", { className: "shrink-0 text-sm font-medium tabular-nums text-foreground", children: amount }),
8571
+ /* @__PURE__ */ jsxs("p", { className: "min-w-0 flex-1 truncate text-xs text-muted-foreground", children: [
8572
+ r.closedByName ?? "\u2014",
8573
+ r.closedAt ? ` \xB7 ${new Date(r.closedAt).toLocaleDateString(void 0, { day: "2-digit", month: "2-digit" })}` : ""
8574
+ ] }),
8575
+ action
8576
+ ] }, r.sessionId);
8577
+ }
8578
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3 px-4 py-3", children: [
8579
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
8580
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium text-foreground", children: amount }),
8581
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
8582
+ t2("financial.cash.closedBy"),
8583
+ " ",
8584
+ r.closedByName ?? "\u2014",
8585
+ r.closedAt ? ` \xB7 ${new Date(r.closedAt).toLocaleTimeString(void 0, HOUR_MINUTE)}` : ""
8586
+ ] })
8587
+ ] }),
8588
+ action
8589
+ ] }, r.sessionId);
8590
+ })
8591
+ ] }, day3.key || "all")),
8592
+ hidden > 0 && /* @__PURE__ */ jsxs(
8593
+ "a",
8594
+ {
8595
+ href: `#/financial/cash/registers?${REVIEW_TAB_QUERY}`,
8596
+ onClick: () => setCashSessionModalOpen(false),
8597
+ className: "flex items-center justify-between px-4 py-2.5 text-xs font-medium text-primary transition-colors hover:bg-muted/50",
8598
+ children: [
8599
+ t2("financial.cash.reviewSeeAll", { count: String(pendentes.length) }),
8600
+ /* @__PURE__ */ jsx(ChevronRight, { className: "h-3.5 w-3.5" })
8601
+ ]
8602
+ }
8603
+ )
8604
+ ] }),
8527
8605
  reviewing && /* @__PURE__ */ jsx(
8528
8606
  CashSessionReviewModal,
8529
8607
  {
@@ -8554,7 +8632,9 @@ function CashRegistersView({ variant = "page" } = {}) {
8554
8632
  const setCashSessionModalOpen = useFinancialStore((s) => s.setCashSessionModalOpen);
8555
8633
  const [expandedClosing, setExpandedClosing] = useState(null);
8556
8634
  const [viewingDetails, setViewingDetails] = useState(null);
8557
- const [tab, setTab] = useState("open");
8635
+ const [tab, setTab] = useState(
8636
+ () => typeof window !== "undefined" && window.location.hash.includes(REVIEW_TAB_QUERY) ? "review" : "open"
8637
+ );
8558
8638
  const [reviewRows, setReviewRows] = useState(null);
8559
8639
  const [reviewError, setReviewError] = useState(null);
8560
8640
  const loadReviews = React29__default.useCallback(async () => {
@@ -8614,49 +8694,52 @@ function CashRegistersView({ variant = "page" } = {}) {
8614
8694
  }
8615
8695
  )
8616
8696
  ] }, session.id)) });
8617
- const closedSessionsList = /* @__PURE__ */ jsx("div", { className: "divide-y", children: closedSessions.map((session) => /* @__PURE__ */ jsx("div", { className: "px-4 py-3", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
8618
- /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
8619
- /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: session.bankAccountName ?? t2("financial.cash.title") }),
8620
- /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
8621
- new Date(session.openedAt).toLocaleDateString(),
8622
- " \u2014 ",
8623
- session.closedAt ? new Date(session.closedAt).toLocaleDateString() : ""
8697
+ const closedSessionsList = /* @__PURE__ */ jsx("div", { className: "divide-y", children: groupByClosingDay(closedSessions).map((day3) => /* @__PURE__ */ jsxs(React29__default.Fragment, { children: [
8698
+ /* @__PURE__ */ jsx(DayHeader, { dayKey: day3.key, count: day3.rows.length }),
8699
+ day3.rows.map((session) => /* @__PURE__ */ jsx("div", { className: "px-4 py-3", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
8700
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
8701
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: session.bankAccountName ?? t2("financial.cash.title") }),
8702
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
8703
+ dayKeyOf(session.openedAt) === dayKeyOf(session.closedAt) ? new Date(session.openedAt).toLocaleTimeString(void 0, HOUR_MINUTE) : new Date(session.openedAt).toLocaleString(void 0, { day: "2-digit", month: "2-digit", ...HOUR_MINUTE }),
8704
+ " \u2014 ",
8705
+ session.closedAt ? new Date(session.closedAt).toLocaleTimeString(void 0, HOUR_MINUTE) : ""
8706
+ ] }),
8707
+ session.openedByName && /* @__PURE__ */ jsxs("p", { className: "text-[10px] text-muted-foreground", children: [
8708
+ t2("financial.cash.by"),
8709
+ " ",
8710
+ session.openedByName
8711
+ ] })
8624
8712
  ] }),
8625
- session.openedByName && /* @__PURE__ */ jsxs("p", { className: "text-[10px] text-muted-foreground", children: [
8626
- t2("financial.cash.by"),
8627
- " ",
8628
- session.openedByName
8629
- ] })
8630
- ] }),
8631
- /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-start gap-3", children: [
8632
- /* @__PURE__ */ jsxs("div", { className: "text-right", children: [
8633
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 text-xs text-muted-foreground", children: [
8634
- /* @__PURE__ */ jsxs("span", { children: [
8635
- t2("financial.cash.openLabel"),
8636
- ": ",
8637
- formatCurrency(session.openingBalance, currency)
8713
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-start gap-3", children: [
8714
+ /* @__PURE__ */ jsxs("div", { className: "text-right", children: [
8715
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 text-xs text-muted-foreground", children: [
8716
+ /* @__PURE__ */ jsxs("span", { children: [
8717
+ t2("financial.cash.openLabel"),
8718
+ ": ",
8719
+ formatCurrency(session.openingBalance, currency)
8720
+ ] }),
8721
+ /* @__PURE__ */ jsxs("span", { children: [
8722
+ t2("financial.cash.closeLabel"),
8723
+ ": ",
8724
+ formatCurrency(session.closingBalance ?? 0, currency)
8725
+ ] })
8638
8726
  ] }),
8639
- /* @__PURE__ */ jsxs("span", { children: [
8640
- t2("financial.cash.closeLabel"),
8727
+ session.difference !== void 0 && session.difference !== 0 && /* @__PURE__ */ jsxs("p", { className: `text-xs font-semibold tabular-nums mt-0.5 ${session.difference > 0 ? "text-success" : "text-destructive"}`, children: [
8728
+ t2("financial.cash.diff"),
8641
8729
  ": ",
8642
- formatCurrency(session.closingBalance ?? 0, currency)
8643
- ] })
8644
- ] }),
8645
- session.difference !== void 0 && session.difference !== 0 && /* @__PURE__ */ jsxs("p", { className: `text-xs font-semibold tabular-nums mt-0.5 ${session.difference > 0 ? "text-success" : "text-destructive"}`, children: [
8646
- t2("financial.cash.diff"),
8647
- ": ",
8648
- session.difference >= 0 ? "+" : "",
8649
- formatCurrency(session.difference, currency)
8730
+ session.difference >= 0 ? "+" : "",
8731
+ formatCurrency(session.difference, currency)
8732
+ ] }),
8733
+ session.notes && /* @__PURE__ */ jsx("p", { className: "text-[10px] text-muted-foreground mt-0.5 max-w-[200px] truncate", title: session.notes, children: session.notes })
8650
8734
  ] }),
8651
- session.notes && /* @__PURE__ */ jsx("p", { className: "text-[10px] text-muted-foreground mt-0.5 max-w-[200px] truncate", title: session.notes, children: session.notes })
8652
- ] }),
8653
- /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => setViewingDetails(session.id), children: [
8654
- /* @__PURE__ */ jsx(Eye, { className: "h-3.5 w-3.5" }),
8655
- " ",
8656
- t2("financial.cash.details")
8735
+ /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => setViewingDetails(session.id), children: [
8736
+ /* @__PURE__ */ jsx(Eye, { className: "h-3.5 w-3.5" }),
8737
+ " ",
8738
+ t2("financial.cash.details")
8739
+ ] })
8657
8740
  ] })
8658
- ] })
8659
- ] }) }, session.id)) });
8741
+ ] }) }, session.id))
8742
+ ] }, day3.key || "all")) });
8660
8743
  return /* @__PURE__ */ jsxs("div", { className: inPanel ? "" : "space-y-4", children: [
8661
8744
  !inPanel && /* @__PURE__ */ jsx(
8662
8745
  SubpageHeader,
@@ -11999,6 +12082,7 @@ function ReconciliationView() {
11999
12082
  value: accountId,
12000
12083
  displayValue: selectedAccount?.name ?? "",
12001
12084
  icon: Landmark,
12085
+ leading: selectedAccount ? /* @__PURE__ */ jsx(AccountIcon, { account: selectedAccount }) : void 0,
12002
12086
  placeholder: "Digite...",
12003
12087
  noResultsLabel: t2("financial.reconciliation.noIntegratedAccount"),
12004
12088
  searchingLabel: "Buscando contas...",
@@ -12016,6 +12100,7 @@ function ReconciliationView() {
12016
12100
  id: account.id,
12017
12101
  label: account.name,
12018
12102
  subtitle: account.bankName || account.accountNumber,
12103
+ icon: /* @__PURE__ */ jsx(AccountIcon, { account }),
12019
12104
  group: ACCOUNT_GROUPS2[account.accountType] ?? "Outras contas",
12020
12105
  data: account
12021
12106
  }));
@@ -20380,6 +20465,9 @@ var en = {
20380
20465
  "financial.cash.by": "by",
20381
20466
  "financial.cash.closeThisSession": "Close this session",
20382
20467
  "financial.cash.reviewQueue": "Close review",
20468
+ "financial.cash.reviewSeeAll": "See all {count} closes",
20469
+ "financial.cash.reviewToday": "Today",
20470
+ "financial.cash.reviewYesterday": "Yesterday",
20383
20471
  "financial.cash.reviewQueueEmpty": "No closes waiting for review.",
20384
20472
  "financial.cash.closedBy": "Closed by",
20385
20473
  "financial.cash.approve": "Approve",
@@ -21336,6 +21424,9 @@ var ptBR = {
21336
21424
  "financial.cash.by": "por",
21337
21425
  "financial.cash.closeThisSession": "Fechar esta sess\xE3o",
21338
21426
  "financial.cash.reviewQueue": "Confer\xEAncia de fechamentos",
21427
+ "financial.cash.reviewSeeAll": "Ver todos os {count} fechamentos",
21428
+ "financial.cash.reviewToday": "Hoje",
21429
+ "financial.cash.reviewYesterday": "Ontem",
21339
21430
  "financial.cash.reviewQueueEmpty": "Nenhum fechamento esperando confer\xEAncia.",
21340
21431
  "financial.cash.closedBy": "Fechado por",
21341
21432
  "financial.cash.approve": "Aprovar",
@@ -48480,6 +48571,374 @@ COMMENT ON COLUMN public.plg_financial_invoices.tags IS 'Free labels the house p
48480
48571
  CREATE INDEX IF NOT EXISTS plg_financial_invoices_tags_idx
48481
48572
  ON public.plg_financial_invoices USING gin (tags);
48482
48573
  `;
48574
+ var MIGRATION_131_THE_BILL_FOLLOWS_THE_PRICE_OF_THE_SALE = `-- 131 \u2014 The bill follows the price of the sale.
48575
+ --
48576
+ -- A receivable is raised when the sale closes, at the total of that instant.
48577
+ -- Both bridges (\`order_raise_receivable\`, \`fn_invoice_from_order_internal\`) are
48578
+ -- idempotent: once the invoice exists they return without looking at the
48579
+ -- amount. So a price changed AFTER closing never reached the bill \u2014 a clinic
48580
+ -- had an appointment at R$ 700 whose receivable still asked for R$ 140, and
48581
+ -- the only symptom was two different numbers on two screens.
48582
+ --
48583
+ -- The rule, in one place:
48584
+ -- * nothing received yet \u2192 the invoice, its lines and its open instalments
48585
+ -- take the sale's total;
48586
+ -- * something received \u2192 nothing moves. A paid invoice is immutable
48587
+ -- (PRD 04, 120); the screens show both numbers and a person decides;
48588
+ -- * the sale drops to zero \u2192 the unpaid invoice is cancelled, not left at 0.
48589
+ --
48590
+ -- The lines follow the model the bill already has. A bill raised here owns a
48591
+ -- copy of the sale's lines (010) and that copy is refreshed. A bill ported
48592
+ -- from V1 has no lines of its own \u2014 \`v_financial_invoice_lines\` reads them
48593
+ -- from the order \u2014 and it stays that way: giving it a copy would freeze lines
48594
+ -- the sync still writes on the order.
48595
+ --
48596
+ -- Receivables of SELLING orders only. A purchase's bill diverges from its
48597
+ -- order on purpose (interest, supplier credit \u2014 120).
48598
+
48599
+ -- \u2500\u2500 1. the reconciliation \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
48600
+ CREATE OR REPLACE FUNCTION public.order_reconcile_receivable_internal(p_order_id uuid)
48601
+ RETURNS text
48602
+ LANGUAGE plpgsql
48603
+ SECURITY DEFINER
48604
+ SET search_path TO ''
48605
+ AS $$
48606
+ DECLARE
48607
+ v_order public.orders%ROWTYPE;
48608
+ v_invoice public.plg_financial_invoices%ROWTYPE;
48609
+ v_total numeric;
48610
+ v_paid numeric;
48611
+ v_open_sum numeric;
48612
+ v_last uuid;
48613
+ v_summary text;
48614
+ v_own boolean;
48615
+ BEGIN
48616
+ SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;
48617
+ IF NOT FOUND OR NOT (v_order.kind = ANY (public.order_selling_kinds())) THEN
48618
+ RETURN 'none';
48619
+ END IF;
48620
+
48621
+ -- (tenant_id, order_id) is the index; naming the tenant is what uses it.
48622
+ SELECT * INTO v_invoice FROM public.plg_financial_invoices
48623
+ WHERE tenant_id = v_order.tenant_id AND order_id = p_order_id
48624
+ AND cancelled_at IS NULL AND direction = 'receivable'
48625
+ ORDER BY created_at LIMIT 1;
48626
+ IF NOT FOUND THEN RETURN 'none'; END IF;
48627
+
48628
+ -- A sale billed across several live invoices has no single bill that should
48629
+ -- carry its whole total. Nobody does this today; deciding it here would be a guess.
48630
+ IF (SELECT count(*) FROM public.plg_financial_invoices
48631
+ WHERE tenant_id = v_order.tenant_id AND order_id = p_order_id
48632
+ AND cancelled_at IS NULL AND direction = 'receivable') > 1 THEN
48633
+ RETURN 'none';
48634
+ END IF;
48635
+
48636
+ v_total := round(coalesce(v_order.total, 0), 2);
48637
+
48638
+ SELECT coalesce(sum(b.paid), 0) INTO v_paid
48639
+ FROM public.v_financial_installment_balances b WHERE b.invoice_id = v_invoice.id;
48640
+
48641
+ v_own := EXISTS (SELECT 1 FROM public.items WHERE invoice_id = v_invoice.id);
48642
+
48643
+ IF round(coalesce(v_invoice.total, 0), 2) = v_total THEN
48644
+ -- Same amount, but the lines may still be the old ones: a screen that
48645
+ -- writes the total and THEN the lines reaches here with the bill already
48646
+ -- at the new amount. A line with no live sale line behind it is stale.
48647
+ IF v_paid = 0 AND v_own AND (
48648
+ (SELECT count(*) FROM public.items WHERE invoice_id = v_invoice.id)
48649
+ IS DISTINCT FROM (SELECT count(*) FROM public.items WHERE order_id = p_order_id)
48650
+ OR EXISTS (
48651
+ SELECT 1 FROM public.items l
48652
+ WHERE l.invoice_id = v_invoice.id
48653
+ AND NOT EXISTS (
48654
+ SELECT 1 FROM public.item_associations a
48655
+ JOIN public.items s ON s.id = a.from_item_id
48656
+ WHERE a.to_item_id = l.id AND s.order_id = p_order_id AND s.total = l.total))
48657
+ ) THEN
48658
+ DELETE FROM public.items WHERE invoice_id = v_invoice.id;
48659
+ PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice.id);
48660
+ END IF;
48661
+ RETURN 'in_sync';
48662
+ END IF;
48663
+
48664
+ IF v_paid > 0 THEN RETURN 'kept_paid'; END IF;
48665
+
48666
+ IF v_total <= 0 THEN
48667
+ UPDATE public.plg_financial_invoices
48668
+ SET cancelled_at = now(),
48669
+ cancel_reason = coalesce(cancel_reason, 'The sale it was raised from no longer has an amount')
48670
+ WHERE id = v_invoice.id;
48671
+ RETURN 'amended';
48672
+ END IF;
48673
+
48674
+ SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary
48675
+ FROM public.items WHERE order_id = p_order_id;
48676
+
48677
+ UPDATE public.plg_financial_invoices
48678
+ SET total = v_total,
48679
+ description = coalesce(nullif(v_summary, ''), description)
48680
+ WHERE id = v_invoice.id;
48681
+
48682
+ -- A stale copy is replaced whole. The copier refuses to run over existing
48683
+ -- lines, hence the delete first.
48684
+ IF v_own THEN
48685
+ DELETE FROM public.items WHERE invoice_id = v_invoice.id;
48686
+ PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice.id);
48687
+ END IF;
48688
+
48689
+ -- The open instalments keep their proportions; the last one takes the cents.
48690
+ SELECT coalesce(sum(amount), 0) INTO v_open_sum
48691
+ FROM public.plg_financial_invoice_installments
48692
+ WHERE invoice_id = v_invoice.id AND cancelled_at IS NULL;
48693
+ SELECT id INTO v_last FROM public.plg_financial_invoice_installments
48694
+ WHERE invoice_id = v_invoice.id AND cancelled_at IS NULL
48695
+ ORDER BY number DESC LIMIT 1;
48696
+
48697
+ IF v_last IS NULL THEN
48698
+ INSERT INTO public.plg_financial_invoice_installments
48699
+ (tenant_id, unit_id, invoice_id, number, due_date, amount)
48700
+ VALUES (v_invoice.tenant_id, v_invoice.unit_id, v_invoice.id,
48701
+ coalesce((SELECT max(number) FROM public.plg_financial_invoice_installments
48702
+ WHERE invoice_id = v_invoice.id), 0) + 1,
48703
+ current_date, v_total);
48704
+ ELSE
48705
+ IF v_open_sum > 0 THEN
48706
+ UPDATE public.plg_financial_invoice_installments
48707
+ SET amount = round(v_total * amount / v_open_sum, 2)
48708
+ WHERE invoice_id = v_invoice.id AND cancelled_at IS NULL AND id <> v_last;
48709
+ END IF;
48710
+ UPDATE public.plg_financial_invoice_installments
48711
+ SET amount = v_total - coalesce((
48712
+ SELECT sum(amount) FROM public.plg_financial_invoice_installments
48713
+ WHERE invoice_id = v_invoice.id AND cancelled_at IS NULL AND id <> v_last), 0)
48714
+ WHERE id = v_last;
48715
+ END IF;
48716
+
48717
+ RETURN 'amended';
48718
+ EXCEPTION WHEN OTHERS THEN
48719
+ -- A closed period, or any other refusal from the books, never fails the
48720
+ -- sale's own write (same rule as raising the receivable, 001). The block
48721
+ -- rolls back whatever it had started, so the bill is left whole.
48722
+ RAISE WARNING 'order_reconcile_receivable: sale % kept its bill as it was: %', p_order_id, SQLERRM;
48723
+ RETURN 'kept_closed';
48724
+ END $$;
48725
+
48726
+ COMMENT ON FUNCTION public.order_reconcile_receivable_internal(uuid) IS
48727
+ 'Makes an UNPAID receivable take its sale''s current total, lines and instalments. Returns none | in_sync | amended | kept_paid | kept_closed. A paid invoice is never touched (131).';
48728
+
48729
+ REVOKE ALL ON FUNCTION public.order_reconcile_receivable_internal(uuid) FROM PUBLIC, anon, authenticated;
48730
+ GRANT EXECUTE ON FUNCTION public.order_reconcile_receivable_internal(uuid) TO service_role;
48731
+
48732
+ -- \u2500\u2500 2. the door a screen uses after rewriting the sale's lines \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
48733
+ -- The trigger below fires on the total; a screen that writes the total and
48734
+ -- THEN the lines (the agenda does) calls this once it is done, so the bill's
48735
+ -- lines are the final ones.
48736
+ CREATE OR REPLACE FUNCTION public.order_reconcile_receivable(p_order_id uuid)
48737
+ RETURNS text
48738
+ LANGUAGE plpgsql
48739
+ SECURITY DEFINER
48740
+ SET search_path TO ''
48741
+ AS $$
48742
+ DECLARE v_tenant uuid;
48743
+ BEGIN
48744
+ SELECT tenant_id INTO v_tenant FROM public.orders WHERE id = p_order_id;
48745
+ IF v_tenant IS NULL OR v_tenant IS DISTINCT FROM app.current_tenant_id() THEN
48746
+ RAISE EXCEPTION 'order_reconcile_receivable: forbidden' USING ERRCODE = '42501';
48747
+ END IF;
48748
+ RETURN public.order_reconcile_receivable_internal(p_order_id);
48749
+ END $$;
48750
+
48751
+ COMMENT ON FUNCTION public.order_reconcile_receivable(uuid) IS
48752
+ 'Tenant-checked door to order_reconcile_receivable_internal: it only ever aligns a bill with a sale the caller''s tenant already owns (131).';
48753
+
48754
+ REVOKE ALL ON FUNCTION public.order_reconcile_receivable(uuid) FROM PUBLIC, anon;
48755
+ GRANT EXECUTE ON FUNCTION public.order_reconcile_receivable(uuid) TO authenticated, service_role;
48756
+
48757
+ -- \u2500\u2500 3. the guarantee: any writer of the total, known or future \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
48758
+ CREATE OR REPLACE FUNCTION public.trg_order_total_reaches_its_invoice()
48759
+ RETURNS trigger
48760
+ LANGUAGE plpgsql
48761
+ SECURITY DEFINER
48762
+ SET search_path TO ''
48763
+ AS $$
48764
+ BEGIN
48765
+ PERFORM public.order_reconcile_receivable_internal(NEW.id);
48766
+ RETURN NULL;
48767
+ END $$;
48768
+
48769
+ DROP TRIGGER IF EXISTS orders_total_reaches_the_invoice ON public.orders;
48770
+ CREATE TRIGGER orders_total_reaches_the_invoice
48771
+ AFTER UPDATE OF total ON public.orders
48772
+ FOR EACH ROW
48773
+ WHEN (NEW.total IS DISTINCT FROM OLD.total)
48774
+ EXECUTE FUNCTION public.trg_order_total_reaches_its_invoice();
48775
+
48776
+ -- \u2500\u2500 4. raising again is the natural repair, and now it repairs the amount \u2500\u2500\u2500
48777
+ CREATE OR REPLACE FUNCTION public.order_raise_receivable(p_order_id uuid, p_due_date date DEFAULT NULL::date)
48778
+ RETURNS uuid
48779
+ LANGUAGE plpgsql
48780
+ SECURITY DEFINER
48781
+ SET search_path TO ''
48782
+ AS $$
48783
+ DECLARE
48784
+ v_order public.orders%ROWTYPE;
48785
+ v_invoice_id uuid;
48786
+ v_number text;
48787
+ v_summary text;
48788
+ v_total numeric;
48789
+ BEGIN
48790
+ SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;
48791
+ IF NOT FOUND THEN RETURN NULL; END IF;
48792
+
48793
+ SELECT id INTO v_invoice_id FROM public.plg_financial_invoices
48794
+ WHERE order_id = p_order_id AND cancelled_at IS NULL LIMIT 1;
48795
+ IF v_invoice_id IS NOT NULL THEN
48796
+ -- Re-raising stamps the lines on a bill that has none, as before (010),
48797
+ -- and now also repairs the amount.
48798
+ PERFORM public.order_reconcile_receivable_internal(p_order_id);
48799
+ PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice_id);
48800
+ RETURN v_invoice_id;
48801
+ END IF;
48802
+
48803
+ v_total := round(coalesce(v_order.total, 0), 2);
48804
+ IF v_total <= 0 THEN RETURN NULL; END IF;
48805
+
48806
+ SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary
48807
+ FROM public.items WHERE order_id = p_order_id;
48808
+
48809
+ v_number := public.plg_financial_next_invoice_number(v_order.tenant_id, 'receivable');
48810
+
48811
+ INSERT INTO public.plg_financial_invoices
48812
+ (tenant_id, unit_id, direction, counterparty_person_id, order_id, number,
48813
+ competence_date, issue_date, total, description, metadata, created_by)
48814
+ VALUES
48815
+ (v_order.tenant_id, v_order.unit_id, 'receivable', v_order.party_id, p_order_id, v_number,
48816
+ coalesce(v_order.created_at::date, current_date), current_date,
48817
+ v_total, nullif(v_summary, ''),
48818
+ jsonb_build_object('source', 'order_raise_receivable', 'orderKind', v_order.kind),
48819
+ auth.uid())
48820
+ RETURNING id INTO v_invoice_id;
48821
+
48822
+ PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice_id);
48823
+
48824
+ INSERT INTO public.plg_financial_invoice_installments
48825
+ (tenant_id, unit_id, invoice_id, number, due_date, amount)
48826
+ VALUES (v_order.tenant_id, v_order.unit_id, v_invoice_id, 1,
48827
+ coalesce(p_due_date, current_date), v_total);
48828
+
48829
+ UPDATE public.orders SET
48830
+ stage = 'invoiced',
48831
+ direction = 'credit',
48832
+ metadata = coalesce(metadata, '{}'::jsonb)
48833
+ || jsonb_build_object('invoiceNumber', v_number, 'installmentCount', 1),
48834
+ updated_at = now()
48835
+ WHERE id = p_order_id;
48836
+
48837
+ RETURN v_invoice_id;
48838
+ END $$;
48839
+
48840
+ CREATE OR REPLACE FUNCTION public.fn_invoice_from_order_internal(p_order_id uuid, p_due_date date DEFAULT NULL::date, p_status text DEFAULT NULL::text, p_installments integer DEFAULT 1, p_direction text DEFAULT 'credit'::text)
48841
+ RETURNS uuid
48842
+ LANGUAGE plpgsql
48843
+ SECURITY DEFINER
48844
+ SET search_path TO ''
48845
+ AS $$
48846
+ DECLARE
48847
+ v_order public.orders%ROWTYPE;
48848
+ v_invoice_id uuid;
48849
+ v_dir text;
48850
+ v_number text;
48851
+ v_summary text;
48852
+ v_contact text;
48853
+ v_n int := GREATEST(COALESCE(p_installments, 1), 1);
48854
+ v_total numeric;
48855
+ v_base numeric;
48856
+ v_amt numeric;
48857
+ v_alloc numeric := 0;
48858
+ v_due date;
48859
+ v_status text;
48860
+ i int;
48861
+ BEGIN
48862
+ SELECT * INTO v_order FROM public.orders WHERE id = p_order_id;
48863
+ IF NOT FOUND THEN RAISE EXCEPTION 'fn_invoice_from_order: order % not found', p_order_id; END IF;
48864
+
48865
+ SELECT id INTO v_invoice_id FROM public.plg_financial_invoices
48866
+ WHERE order_id = p_order_id AND cancelled_at IS NULL LIMIT 1;
48867
+ IF v_invoice_id IS NOT NULL THEN
48868
+ -- Idempotent, and the natural repair: an order invoiced before 010 gets its
48869
+ -- lines, and one whose price moved since gets its amount (131). The
48870
+ -- reconciliation is a no-op on anything but an unpaid receivable.
48871
+ PERFORM public.order_reconcile_receivable_internal(p_order_id);
48872
+ PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice_id);
48873
+ RETURN p_order_id;
48874
+ END IF;
48875
+ IF EXISTS (SELECT 1 FROM public.plg_financial_movements
48876
+ WHERE invoice_id = p_order_id AND movement_kind = 'bill'
48877
+ AND status <> 'cancelled') THEN
48878
+ RETURN p_order_id;
48879
+ END IF;
48880
+
48881
+ v_dir := CASE WHEN p_direction = 'debit' THEN 'payable' ELSE 'receivable' END;
48882
+ v_number := public.plg_financial_next_invoice_number(v_order.tenant_id, v_dir);
48883
+
48884
+ SELECT string_agg(name, ', ' ORDER BY sort_order) INTO v_summary
48885
+ FROM public.items WHERE order_id = p_order_id;
48886
+ SELECT name INTO v_contact FROM public.people WHERE id = v_order.party_id;
48887
+
48888
+ v_status := v_order.status;
48889
+ IF p_status IS NOT NULL AND EXISTS (
48890
+ SELECT 1 FROM public.order_kinds k
48891
+ WHERE k.kind = v_order.kind
48892
+ AND (k.statuses IS NULL OR p_status = ANY (k.statuses))
48893
+ ) THEN
48894
+ v_status := p_status;
48895
+ END IF;
48896
+
48897
+ UPDATE public.orders SET
48898
+ status = v_status,
48899
+ stage = 'invoiced',
48900
+ direction = p_direction,
48901
+ metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
48902
+ 'itemsSummary', COALESCE(v_summary, ''),
48903
+ 'contactName', v_contact,
48904
+ 'installmentCount', v_n,
48905
+ 'quoteNumber', v_order.reference_number,
48906
+ 'direction', p_direction,
48907
+ 'invoiceNumber', v_number
48908
+ ),
48909
+ updated_at = now()
48910
+ WHERE id = p_order_id;
48911
+
48912
+ v_total := round(COALESCE(v_order.total, 0), 2);
48913
+ v_due := COALESCE(p_due_date, current_date);
48914
+
48915
+ INSERT INTO public.plg_financial_invoices
48916
+ (tenant_id, unit_id, direction, counterparty_person_id, order_id, number, competence_date, issue_date,
48917
+ total, description, metadata, created_by)
48918
+ VALUES
48919
+ (v_order.tenant_id, v_order.unit_id, v_dir, v_order.party_id, p_order_id, v_number,
48920
+ COALESCE(v_order.created_at::date, current_date), current_date,
48921
+ v_total, NULLIF(v_summary, ''),
48922
+ jsonb_build_object('source', 'fn_invoice_from_order', 'orderKind', v_order.kind),
48923
+ auth.uid())
48924
+ RETURNING id INTO v_invoice_id;
48925
+
48926
+ PERFORM public.plg_financial_copy_order_lines_to_invoice(p_order_id, v_invoice_id);
48927
+
48928
+ v_base := round(v_total / v_n, 2);
48929
+ FOR i IN 1..v_n LOOP
48930
+ IF i = v_n THEN v_amt := round(v_total - v_alloc, 2);
48931
+ ELSE v_amt := v_base; v_alloc := v_alloc + v_base; END IF;
48932
+
48933
+ INSERT INTO public.plg_financial_invoice_installments
48934
+ (tenant_id, unit_id, invoice_id, number, due_date, amount)
48935
+ VALUES (v_order.tenant_id, v_order.unit_id, v_invoice_id, i,
48936
+ (v_due + ((i - 1) || ' months')::interval)::date, v_amt);
48937
+ END LOOP;
48938
+
48939
+ RETURN p_order_id;
48940
+ END $$;
48941
+ `;
48483
48942
  var MIGRATIONS = [
48484
48943
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
48485
48944
  { id: "001_the_comanda_becomes_a_receivable_when_it_closes", sql: MIGRATION_001_THE_COMANDA_BECOMES_A_RECEIVABLE_WHEN_IT_CLOSES },
@@ -48611,7 +49070,8 @@ var MIGRATIONS = [
48611
49070
  { id: "127_a_bill_line_enters_stock_as_it_really_arrived", sql: MIGRATION_127_A_BILL_LINE_ENTERS_STOCK_AS_IT_REALLY_ARRIVED },
48612
49071
  { id: "128_a_stock_receipt_shows_what_was_paid", sql: MIGRATION_128_A_STOCK_RECEIPT_SHOWS_WHAT_WAS_PAID },
48613
49072
  { id: "129_a_bill_can_be_written_in_another_currency", sql: MIGRATION_129_A_BILL_CAN_BE_WRITTEN_IN_ANOTHER_CURRENCY },
48614
- { id: "130_a_bill_can_be_tagged", sql: MIGRATION_130_A_BILL_CAN_BE_TAGGED }
49073
+ { id: "130_a_bill_can_be_tagged", sql: MIGRATION_130_A_BILL_CAN_BE_TAGGED },
49074
+ { id: "131_the_bill_follows_the_price_of_the_sale", sql: MIGRATION_131_THE_BILL_FOLLOWS_THE_PRICE_OF_THE_SALE }
48615
49075
  ];
48616
49076
  var STANDALONE_CONFIG = resolveConfig();
48617
49077
  registerTranslations(financialLocales);