@kahitsan/ksui 0.46.0 → 0.47.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,6 +1,5 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { render } from "@solidjs/testing-library";
3
- import { Show, createSignal } from "solid-js";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { cleanup, render } from "@solidjs/testing-library";
4
3
  import Modal from "./Modal";
5
4
 
6
5
  // Modal is the most critical shared widget — it wraps a native <dialog> with
@@ -68,4 +67,71 @@ describe("Modal", () => {
68
67
  const card = container.querySelector(".ksui-modal-card")!;
69
68
  expect(card.className).toContain("danger");
70
69
  });
70
+
71
+ describe("sheet variant", () => {
72
+ // jsdom has no showModal()/close(); stand them in so a stacked dialog is open.
73
+ const proto = HTMLDialogElement.prototype;
74
+ const original = { showModal: proto.showModal, close: proto.close };
75
+ beforeEach(() => {
76
+ proto.showModal = function (this: HTMLDialogElement) {
77
+ this.setAttribute("open", "");
78
+ };
79
+ proto.close = function (this: HTMLDialogElement) {
80
+ this.removeAttribute("open");
81
+ };
82
+ });
83
+ afterEach(() => {
84
+ cleanup(); // unmount while the stubs still exist
85
+ proto.showModal = original.showModal;
86
+ proto.close = original.close;
87
+ });
88
+
89
+ it("calls onClose on Escape", () => {
90
+ const onClose = vi.fn();
91
+ render(() => (
92
+ <Modal variant="sheet" onClose={onClose}>
93
+ <p>sheet</p>
94
+ </Modal>
95
+ ));
96
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
97
+ expect(onClose).toHaveBeenCalledOnce();
98
+ });
99
+
100
+ it("leaves Escape to a dialog opened on top of it", () => {
101
+ const onSheetClose = vi.fn();
102
+ const onDialogClose = vi.fn();
103
+ const { container } = render(() => (
104
+ <Modal variant="sheet" onClose={onSheetClose}>
105
+ <Modal onClose={onDialogClose}>
106
+ <button type="button">inside</button>
107
+ </Modal>
108
+ </Modal>
109
+ ));
110
+ const dialog = container.querySelector("dialog")!;
111
+ expect(dialog.open).toBe(true);
112
+
113
+ // A browser delivers Escape as keydown (bubbling to window) and then
114
+ // fires cancel on the topmost modal dialog.
115
+ dialog
116
+ .querySelector("button")!
117
+ .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
118
+ dialog.dispatchEvent(new Event("cancel", { cancelable: true }));
119
+
120
+ expect(onSheetClose).not.toHaveBeenCalled();
121
+ expect(onDialogClose).toHaveBeenCalledOnce();
122
+ });
123
+
124
+ it("ignores an Escape keydown another handler already consumed", () => {
125
+ const onClose = vi.fn();
126
+ render(() => (
127
+ <Modal variant="sheet" onClose={onClose}>
128
+ <p>sheet</p>
129
+ </Modal>
130
+ ));
131
+ const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
132
+ e.preventDefault();
133
+ window.dispatchEvent(e);
134
+ expect(onClose).not.toHaveBeenCalled();
135
+ });
136
+ });
71
137
  });
@@ -155,13 +155,28 @@ function DialogModal(local: LocalProps): JSX.Element {
155
155
  );
156
156
  }
157
157
 
158
+ function openModalDialogs(): HTMLDialogElement[] {
159
+ const open = [...document.querySelectorAll<HTMLDialogElement>("dialog[open]")];
160
+ try {
161
+ return open.filter((d) => d.matches(":modal"));
162
+ } catch {
163
+ // Engines without :modal support; treat every open dialog as modal.
164
+ return open;
165
+ }
166
+ }
167
+
158
168
  function SheetModal(local: LocalProps, size?: ModalSize): JSX.Element {
169
+ let overlayEl: HTMLDivElement | undefined;
170
+
159
171
  lockPullToRefresh();
160
172
  onCleanup(unlockPullToRefresh);
161
173
 
162
174
  const handleKey = (e: KeyboardEvent) => {
163
- if (e.key !== "Escape") return;
175
+ if (e.key !== "Escape" || e.defaultPrevented) return;
164
176
  if (local.dismissable === false) return;
177
+ // A modal <dialog> stacked above this sheet owns Escape (it gets `cancel`);
178
+ // closing here too would discard the sheet underneath.
179
+ if (openModalDialogs().some((d) => !d.contains(overlayEl ?? null))) return;
165
180
  local.onClose();
166
181
  };
167
182
  if (typeof window !== "undefined") {
@@ -179,7 +194,7 @@ function SheetModal(local: LocalProps, size?: ModalSize): JSX.Element {
179
194
  // ModalLayerContext value is already ambient (an outer DialogModal's
180
195
  // dialogEl if nested inside one, otherwise the default null/document.body).
181
196
  return (
182
- <div class="ksui-modal-sheet-overlay">
197
+ <div ref={overlayEl} class="ksui-modal-sheet-overlay">
183
198
  {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
184
199
  <div class="ksui-modal-sheet-backdrop" onClick={handleBackdropClick} />
185
200
  <div
@@ -0,0 +1,224 @@
1
+ // Opt-in tax-ID capture next to the payee picker: shows the picked payee's TIN
2
+ // and address, offers inputs when they are missing, and warns when withholding
3
+ // is on but no TIN is known.
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { render, fireEvent, waitFor } from "@solidjs/testing-library";
6
+ import TransactionForm, { type TransactionFormProps } from "./TransactionForm";
7
+ import { isValidTin } from "./payee-tin";
8
+
9
+ function baseProps(overrides: Partial<TransactionFormProps> = {}): TransactionFormProps {
10
+ const noop = vi.fn();
11
+ return {
12
+ error: "",
13
+ saving: false,
14
+ category: "expense",
15
+ setCategory: noop,
16
+ subcategory: "",
17
+ setSubcategory: noop,
18
+ sourceAccount: "",
19
+ setSourceAccount: noop,
20
+ destAccount: "",
21
+ setDestAccount: noop,
22
+ amount: "",
23
+ setAmount: noop,
24
+ description: "",
25
+ setDescription: noop,
26
+ notes: "",
27
+ setNotes: noop,
28
+ date: "2026-09-10",
29
+ setDate: noop,
30
+ isPrivate: false,
31
+ setIsPrivate: noop,
32
+ sharedWith: [],
33
+ setSharedWith: noop,
34
+ sharedRoleCodes: [],
35
+ setSharedRoleCodes: noop,
36
+ backdateReason: "",
37
+ setBackdateReason: noop,
38
+ payee: "Acme Hardware",
39
+ setPayee: noop,
40
+ payeeId: 7,
41
+ setPayeeId: noop,
42
+ refNumber: "",
43
+ setRefNumber: noop,
44
+ taxType: "vat_inclusive",
45
+ setTaxType: noop,
46
+ hasEwt: false,
47
+ setHasEwt: noop,
48
+ ewtRate: "",
49
+ setEwtRate: noop,
50
+ payableKind: "",
51
+ setPayableKind: noop,
52
+ dueDate: "",
53
+ setDueDate: noop,
54
+ chequeNumber: "",
55
+ setChequeNumber: noop,
56
+ pdcStatus: "",
57
+ setPdcStatus: noop,
58
+ transferFeeEnabled: false,
59
+ setTransferFeeEnabled: noop,
60
+ transferFeeAmount: "",
61
+ setTransferFeeAmount: noop,
62
+ allowTransferFee: false,
63
+ pendingFiles: [],
64
+ setPendingFiles: noop,
65
+ accounts: [],
66
+ orgMembers: [],
67
+ shareableRoles: [],
68
+ isAdmin: true,
69
+ canShare: false,
70
+ isBackdated: false,
71
+ saleItems: [],
72
+ setSaleItems: noop,
73
+ saleClient: null,
74
+ setSaleClient: noop,
75
+ saleVoucher: null,
76
+ setSaleVoucher: noop,
77
+ saleManualDiscount: "",
78
+ setSaleManualDiscount: noop,
79
+ onSubmit: noop,
80
+ submitLabel: "Save",
81
+ ...overrides,
82
+ };
83
+ }
84
+
85
+ const tinProps = (extra: Partial<TransactionFormProps> = {}) =>
86
+ baseProps({
87
+ payeeTin: "",
88
+ setPayeeTin: vi.fn(),
89
+ payeeAddress: "",
90
+ setPayeeAddress: vi.fn(),
91
+ ...extra,
92
+ });
93
+
94
+ function mockPayee(record: Record<string, unknown>) {
95
+ vi.stubGlobal(
96
+ "fetch",
97
+ vi.fn(async () => new Response(JSON.stringify(record), { status: 200 })),
98
+ );
99
+ }
100
+
101
+ beforeEach(() => vi.unstubAllGlobals());
102
+ afterEach(() => vi.unstubAllGlobals());
103
+
104
+ describe("isValidTin", () => {
105
+ it("accepts 9 or 12 digits with or without dashes", () => {
106
+ expect(isValidTin("123456789")).toBe(true);
107
+ expect(isValidTin("123-456-789")).toBe(true);
108
+ expect(isValidTin("123-456-789-000")).toBe(true);
109
+ expect(isValidTin("123456789000")).toBe(true);
110
+ });
111
+ it("rejects other lengths and non-digits", () => {
112
+ expect(isValidTin("12345678")).toBe(false);
113
+ expect(isValidTin("1234567890")).toBe(false);
114
+ expect(isValidTin("12a-456-789")).toBe(false);
115
+ });
116
+ it("treats blank as valid because the field is optional", () => {
117
+ expect(isValidTin("")).toBe(true);
118
+ expect(isValidTin(" ")).toBe(true);
119
+ });
120
+ });
121
+
122
+ describe("TransactionForm payee tax ID", () => {
123
+ it("renders nothing extra when the host does not opt in", async () => {
124
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
125
+ const { queryByTestId } = render(() => <TransactionForm {...baseProps()} />);
126
+ await Promise.resolve();
127
+ expect(queryByTestId("form-payee-tin-input")).toBeNull();
128
+ expect(queryByTestId("form-payee-tin-readonly")).toBeNull();
129
+ });
130
+
131
+ it("shows the saved TIN and address read-only and offers no input", async () => {
132
+ mockPayee({
133
+ id: 7,
134
+ name: "Acme Hardware",
135
+ kind: "vendor",
136
+ tin: "123-456-789-00000",
137
+ address: "1 Main St",
138
+ });
139
+ const { findByTestId, queryByTestId } = render(() => <TransactionForm {...tinProps()} />);
140
+ const line = await findByTestId("form-payee-tin-readonly");
141
+ expect(line.textContent).toContain("123-456-789-00000");
142
+ expect(line.textContent).toContain("1 Main St");
143
+ expect(queryByTestId("form-payee-tin-input")).toBeNull();
144
+ expect(queryByTestId("form-payee-address-input")).toBeNull();
145
+ });
146
+
147
+ it("offers TIN and address inputs when the payee has neither", async () => {
148
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
149
+ const setPayeeTin = vi.fn();
150
+ const setPayeeAddress = vi.fn();
151
+ const { findByTestId } = render(() => (
152
+ <TransactionForm {...tinProps({ setPayeeTin, setPayeeAddress })} />
153
+ ));
154
+ const tin = (await findByTestId("form-payee-tin-input")) as HTMLInputElement;
155
+ fireEvent.input(tin, { target: { value: "123-456-789" } });
156
+ expect(setPayeeTin).toHaveBeenCalledWith("123-456-789");
157
+ const addr = (await findByTestId("form-payee-address-input")) as HTMLInputElement;
158
+ fireEvent.input(addr, { target: { value: "2 Side St" } });
159
+ expect(setPayeeAddress).toHaveBeenCalledWith("2 Side St");
160
+ });
161
+
162
+ it("asks only for the address when the TIN is already saved", async () => {
163
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: "123456789", address: "" });
164
+ const { findByTestId, queryByTestId } = render(() => <TransactionForm {...tinProps()} />);
165
+ await findByTestId("form-payee-address-input");
166
+ expect(queryByTestId("form-payee-tin-input")).toBeNull();
167
+ });
168
+
169
+ it("flags a malformed TIN", async () => {
170
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
171
+ const { findByTestId, queryByTestId } = render(() => (
172
+ <TransactionForm {...tinProps({ payeeTin: "12-34" })} />
173
+ ));
174
+ await findByTestId("form-payee-tin-input");
175
+ expect(queryByTestId("form-payee-tin-error")).toBeTruthy();
176
+ });
177
+
178
+ it("does not flag a valid TIN", async () => {
179
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
180
+ const { findByTestId, queryByTestId } = render(() => (
181
+ <TransactionForm {...tinProps({ payeeTin: "123-456-789" })} />
182
+ ));
183
+ await findByTestId("form-payee-tin-input");
184
+ expect(queryByTestId("form-payee-tin-error")).toBeNull();
185
+ });
186
+
187
+ it("warns, without blocking, when withholding is on and no TIN is known", async () => {
188
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
189
+ const { findByTestId } = render(() => <TransactionForm {...tinProps({ hasEwt: true })} />);
190
+ const warn = await findByTestId("form-payee-tin-ewt-warning");
191
+ expect(warn.textContent).toMatch(/TIN/);
192
+ });
193
+
194
+ it("drops the warning once a TIN is typed or already saved", async () => {
195
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: null, address: null });
196
+ const typed = render(() => (
197
+ <TransactionForm {...tinProps({ hasEwt: true, payeeTin: "123-456-789" })} />
198
+ ));
199
+ await typed.findByTestId("form-payee-tin-input");
200
+ expect(typed.queryByTestId("form-payee-tin-ewt-warning")).toBeNull();
201
+ typed.unmount();
202
+
203
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "vendor", tin: "123456789", address: "x" });
204
+ const saved = render(() => <TransactionForm {...tinProps({ hasEwt: true })} />);
205
+ await saved.findByTestId("form-payee-tin-readonly");
206
+ expect(saved.queryByTestId("form-payee-tin-ewt-warning")).toBeNull();
207
+ });
208
+
209
+ it("shows nothing before a payee is chosen", async () => {
210
+ mockPayee({});
211
+ const { queryByTestId } = render(() => (
212
+ <TransactionForm {...tinProps({ payee: "", payeeId: null, hasEwt: true })} />
213
+ ));
214
+ await waitFor(() => expect(queryByTestId("form-payee-tin-input")).toBeNull());
215
+ expect(queryByTestId("form-payee-tin-ewt-warning")).toBeNull();
216
+ });
217
+
218
+ it("stays out of sales", async () => {
219
+ mockPayee({ id: 7, name: "Acme Hardware", kind: "customer", tin: null, address: null });
220
+ const { queryByTestId } = render(() => <TransactionForm {...tinProps({ category: "sale" })} />);
221
+ await Promise.resolve();
222
+ expect(queryByTestId("form-payee-tin-input")).toBeNull();
223
+ });
224
+ });
@@ -25,6 +25,7 @@ import TransactionAccountFields from "./TransactionAccountFields";
25
25
  import TransactionPayableFields from "./TransactionPayableFields";
26
26
  import ComboBox from "./ComboBox";
27
27
  import type { ClientOption, PayeeOption, PayeeKind } from "./picker-types";
28
+ import { isValidTin } from "./payee-tin";
28
29
  import VoucherPicker, { type VoucherOption } from "./VoucherPicker";
29
30
  import MentionTextarea from "./MentionTextarea";
30
31
  import SearchableSelect from "./SearchableSelect";
@@ -105,6 +106,13 @@ async function createPayee(
105
106
  return (await res.json()) as PayeeOption;
106
107
  }
107
108
 
109
+ async function loadPayee(id: number): Promise<PayeeOption | null> {
110
+ const r = await fetch(`/api/payees/${id}`, { credentials: "include" });
111
+ if (!r.ok) return null;
112
+ const json = (await r.json()) as PayeeOption & { data?: PayeeOption };
113
+ return json.data ?? json;
114
+ }
115
+
108
116
  function payeeSecondary(p: PayeeOption): string | null {
109
117
  if (!p.default_subcategory && p.kind === "vendor") return null;
110
118
  return (
@@ -248,6 +256,13 @@ export interface TransactionFormProps {
248
256
  setPayee: (v: string) => void;
249
257
  payeeId: number | null;
250
258
  setPayeeId: (v: number | null) => void;
259
+ /** Opt in to tax-ID capture for the picked payee by supplying `setPayeeTin`.
260
+ * The form shows the saved TIN/address, or inputs when they are missing, and
261
+ * reports typed values here; the host decides when and how to save them. */
262
+ payeeTin?: string;
263
+ setPayeeTin?: (v: string) => void;
264
+ payeeAddress?: string;
265
+ setPayeeAddress?: (v: string) => void;
251
266
  refNumber: string;
252
267
  setRefNumber: (v: string) => void;
253
268
  taxType: string;
@@ -360,6 +375,27 @@ export default function TransactionForm(props: TransactionFormProps) {
360
375
  setSelectedPayee(null);
361
376
  }
362
377
  });
378
+ // A payee picked from search already carries its tax ID; one restored from a
379
+ // saved row does not, so load it before deciding what to show.
380
+ createEffect(() => {
381
+ const sel = selectedPayee();
382
+ if (!props.setPayeeTin || !sel || sel.tin !== undefined) return;
383
+ const id = sel.id;
384
+ loadPayee(id)
385
+ .then((rec) => {
386
+ const cur = selectedPayee();
387
+ if (!rec || !cur || cur.id !== id) return;
388
+ setSelectedPayee({ ...cur, tin: rec.tin ?? null, address: rec.address ?? null });
389
+ })
390
+ .catch(() => {});
391
+ });
392
+ const taxPayee = () =>
393
+ props.setPayeeTin && props.category !== "sale" && catConfig().showPayee
394
+ ? selectedPayee()
395
+ : null;
396
+ const savedTin = () => !!taxPayee()?.tin?.trim();
397
+ const savedAddress = () => !!taxPayee()?.address?.trim();
398
+ const tinKnown = () => taxPayee()?.tin !== undefined;
363
399
  let dragCounter = 0;
364
400
  let formFileInput: HTMLInputElement | undefined;
365
401
 
@@ -756,6 +792,8 @@ export default function TransactionForm(props: TransactionFormProps) {
756
792
  setSelectedPayee(p);
757
793
  props.setPayee(p ? p.name : "");
758
794
  props.setPayeeId(p ? p.id : null);
795
+ props.setPayeeTin?.("");
796
+ props.setPayeeAddress?.("");
759
797
  }}
760
798
  />
761
799
  </FormField>
@@ -774,6 +812,62 @@ export default function TransactionForm(props: TransactionFormProps) {
774
812
  </Show>
775
813
  </FormField>
776
814
  </div>
815
+ <Show when={taxPayee() && tinKnown()}>
816
+ <div class="space-y-2" data-testid="form-payee-tax-id">
817
+ <Show when={savedTin()}>
818
+ <p
819
+ data-testid="form-payee-tin-readonly"
820
+ class="text-xs text-[var(--ks-fg-subtle,#71717a)]"
821
+ >
822
+ TIN {taxPayee()!.tin}
823
+ {savedAddress() ? ` · ${taxPayee()!.address}` : ""}
824
+ </p>
825
+ </Show>
826
+ <div class="grid grid-cols-2 gap-4">
827
+ <Show when={!savedTin()}>
828
+ <FormField label="TIN (optional)">
829
+ <input
830
+ type="text"
831
+ inputmode="numeric"
832
+ data-testid="form-payee-tin-input"
833
+ value={props.payeeTin ?? ""}
834
+ onInput={(e) => props.setPayeeTin!(e.currentTarget.value)}
835
+ class="w-full bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)] border border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] px-3 py-3 text-sm text-[var(--ks-fg,#ffffff)] ks-hud-clip-button focus:outline-none focus:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)]"
836
+ placeholder="000-000-000-000"
837
+ />
838
+ <Show when={!isValidTin(props.payeeTin ?? "")}>
839
+ <p
840
+ data-testid="form-payee-tin-error"
841
+ class="mt-1 text-[10px] text-[var(--ks-danger,#ef4444)]"
842
+ >
843
+ A TIN has 9 digits, or 12 with the branch code.
844
+ </p>
845
+ </Show>
846
+ </FormField>
847
+ </Show>
848
+ <Show when={!savedAddress() && props.setPayeeAddress}>
849
+ <FormField label="Address (optional)">
850
+ <input
851
+ type="text"
852
+ data-testid="form-payee-address-input"
853
+ value={props.payeeAddress ?? ""}
854
+ onInput={(e) => props.setPayeeAddress!(e.currentTarget.value)}
855
+ class="w-full bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_60%,transparent)] border border-[color-mix(in_srgb,var(--ks-border,rgba(39,39,42,0.5))_60%,transparent)] px-3 py-3 text-sm text-[var(--ks-fg,#ffffff)] ks-hud-clip-button focus:outline-none focus:border-[color-mix(in_srgb,var(--ks-focus-ring,#c9a961)_50%,transparent)]"
856
+ placeholder="Printed on the invoice"
857
+ />
858
+ </FormField>
859
+ </Show>
860
+ </div>
861
+ <Show when={props.hasEwt && !savedTin() && !(props.payeeTin ?? "").trim()}>
862
+ <p
863
+ data-testid="form-payee-tin-ewt-warning"
864
+ class="text-[11px] text-[var(--ks-warning,#f59e0b)]"
865
+ >
866
+ Withholding needs the payee's TIN for the 2307 certificate. You can save without it.
867
+ </p>
868
+ </Show>
869
+ </div>
870
+ </Show>
777
871
  </Show>
778
872
  <Show when={!catConfig().showPayee}>
779
873
  <FormField label="Reference #">
@@ -0,0 +1,15 @@
1
+ // Tax-ID helpers shared by the transaction form and its hosts, so both sides
2
+ // apply the same rule before a TIN is saved.
3
+
4
+ const TIN_PATTERN = /^\d{9}(\d{3})?$/;
5
+
6
+ /** Trims the input and drops dashes and spaces: "123-456-789" -> "123456789". */
7
+ export function normalizeTin(value: string): string {
8
+ return value.replace(/[\s-]/g, "");
9
+ }
10
+
11
+ /** A TIN is 9 digits, or 12 with the branch code. Blank is valid (optional). */
12
+ export function isValidTin(value: string): boolean {
13
+ const digits = normalizeTin(value);
14
+ return digits === "" || TIN_PATTERN.test(digits);
15
+ }
@@ -14,6 +14,9 @@ export interface PayeeOption {
14
14
  kind: PayeeKind;
15
15
  default_subcategory?: string | null;
16
16
  notes?: string | null;
17
+ /** Tax ID on record. `undefined` means not loaded yet; `null`/"" means none. */
18
+ tin?: string | null;
19
+ address?: string | null;
17
20
  is_active?: boolean;
18
21
  }
19
22
 
package/src/index.ts CHANGED
@@ -212,6 +212,7 @@ export {
212
212
  // Shared domain option shapes for the common pickers, decoupled from any
213
213
  // component (still imported across transactions / counter / payees).
214
214
  export type { ClientOption, PayeeOption, PayeeKind } from "./components/composite/picker-types";
215
+ export { isValidTin, normalizeTin } from "./components/composite/payee-tin";
215
216
 
216
217
  export { default as VoucherPicker, calculateDiscount } from "./components/composite/VoucherPicker";
217
218
  export type { VoucherOption } from "./components/composite/VoucherPicker";