@kahitsan/ksui 0.46.1 → 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.1",
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",
@@ -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";