@tallyui/connector-woocommerce 3.0.4 → 3.1.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/README.md CHANGED
@@ -35,6 +35,15 @@ The incremental pull can miss an edit: a `modified_after` that over-excludes (in
35
35
  - When `wcpos/v2/status` lists `products_id_fast_path` in `capabilities`, the whole catalogue is listed in one request (`per_page=-1` with `_fields=id,date_modified_gmt,stock_quantity,stock_status`) instead of pages of 100; a store that refuses it (a 400) is listed page by page in the same pass.
36
36
  - Both listings key on the numeric product id, never the till-local uuid; the adapter's `matchKey` is how the runner finds the till's copy.
37
37
 
38
+ ## Customers
39
+
40
+ On WCPOS 1.10.x, `searchCustomers` uses `GET wcpos/v2/customers` with `role=customer`.
41
+ `getCustomer` uses the same route with `include=<id>&per_page=1&role=all`; there is no per-id route.
42
+ `createCustomer` uses `POST wcpos/v2/push/customers`. Create is idempotent per call through the WCPOS mutation id;
43
+ a repeat call after a lost answer uses new ids and gives `invalid` (email exists). There is no automatic retry.
44
+ `emailReceipt` uses `POST wcpos/v2/orders/<id>/email`, optionally saving the email to billing.
45
+ It is not idempotent: each call sends a mail, so don't auto-retry. The cashier needs `access_woocommerce_pos`.
46
+
38
47
  ## Errors
39
48
 
40
49
  - `ConnectorUnauthorizedError`, from `@tallyui/core` and re-exported here: the store refused the request (401: the token is missing, expired or revoked; 403: signed in but not allowed), or `WooMissingTokenError` (a subclass) was thrown for a missing token. `error.status` is `401` (`code: 'unauthorized'`, sign in again) or `403` (`code: 'forbidden'`, signed in but not allowed: the pull retries on the store schedule; don't sign out).
package/dist/index.d.ts CHANGED
@@ -1,7 +1,34 @@
1
- import { OrderCreateEnvelope, CommandResult, ProductTraits, CollectionSync, ReplicationAdapter, ReconcileFeed, CatalogueReconcileAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
1
+ import { SyncContext, Customer, CustomerInput, OrderCreateEnvelope, CommandResult, ProductTraits, CollectionSync, ReplicationAdapter, ReconcileFeed, CatalogueReconcileAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
2
2
  export { ConnectorUnauthorizedError } from '@tallyui/core';
3
3
  import { RxJsonSchema } from 'rxdb';
4
4
 
5
+ type WooCustomerRow = {
6
+ id: number | string;
7
+ first_name?: string | null;
8
+ last_name?: string | null;
9
+ email?: string | null;
10
+ billing?: {
11
+ first_name?: string | null;
12
+ last_name?: string | null;
13
+ email?: string | null;
14
+ phone?: string | null;
15
+ company?: string | null;
16
+ } | null;
17
+ };
18
+ declare function toWooCustomer(raw: WooCustomerRow): Customer;
19
+ declare function createWooCustomers(options?: {
20
+ newId?: () => string;
21
+ }): {
22
+ searchCustomers(context: SyncContext, query: string, options?: {
23
+ limit?: number;
24
+ }): Promise<Customer[]>;
25
+ getCustomer(context: SyncContext, id: string): Promise<Customer | null>;
26
+ createCustomer(context: SyncContext, input: CustomerInput): Promise<Customer>;
27
+ emailReceipt(context: SyncContext, orderId: string, email: string, options?: {
28
+ saveToBilling?: boolean;
29
+ }): Promise<void>;
30
+ };
31
+
5
32
  type WooTransportOutcome = {
6
33
  kind: 'results';
7
34
  results: CommandResult[];
@@ -171,4 +198,4 @@ declare function createWooCommerceConnector(): TallyConnector;
171
198
  */
172
199
  declare const woocommerceConnector: TallyConnector;
173
200
 
174
- export { type WooCommandTransportOptions, WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommandTransport, createWooCommerceConnector, toWooOrderPayload, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
201
+ export { type WooCommandTransportOptions, WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommandTransport, createWooCommerceConnector, createWooCustomers, toWooCustomer, toWooOrderPayload, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
package/dist/index.js CHANGED
@@ -592,8 +592,109 @@ function createWooReconcileFeed() {
592
592
  return createReconcileFeed({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchByIds });
593
593
  }
594
594
 
595
+ // src/customers.ts
596
+ import { CustomerServiceError } from "@tallyui/core";
597
+ function toWooCustomer(raw) {
598
+ const customer = { id: String(raw.id), name: String(raw.id) };
599
+ for (const [key, value] of [
600
+ ["firstName", raw.first_name || raw.billing?.first_name],
601
+ ["lastName", raw.last_name || raw.billing?.last_name],
602
+ ["email", raw.email || raw.billing?.email],
603
+ ["phone", raw.billing?.phone],
604
+ ["company", raw.billing?.company]
605
+ ]) {
606
+ if (typeof value === "string" && value.length > 0) customer[key] = value;
607
+ }
608
+ customer.name = [customer.firstName, customer.lastName].filter(Boolean).join(" ") || customer.company || customer.email || customer.id;
609
+ return customer;
610
+ }
611
+ function newWooId() {
612
+ if (!globalThis.crypto?.getRandomValues) throw new Error("connector-woocommerce: no random source");
613
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
614
+ bytes[6] = bytes[6] & 15 | 64;
615
+ bytes[8] = bytes[8] & 63 | 128;
616
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
617
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
618
+ }
619
+ async function customerRequest(context, route, init = {}) {
620
+ let response;
621
+ try {
622
+ response = await fetch(`${context.baseUrl}/${route}`, {
623
+ ...init,
624
+ headers: { ...init.headers, ...context.headers },
625
+ signal: context.signal
626
+ });
627
+ } catch (error) {
628
+ throw new CustomerServiceError("network", error instanceof Error ? error.message : String(error));
629
+ }
630
+ if ([401, 403, 426].includes(response.status)) await checkResponse(response);
631
+ const body = await response.json().catch(() => void 0);
632
+ if (!response.ok) {
633
+ const invalid = [400, 404, 422].includes(response.status);
634
+ throw new CustomerServiceError(invalid ? "invalid" : "server", invalid ? body?.message ?? `HTTP ${response.status}` : `HTTP ${response.status}`);
635
+ }
636
+ return { status: response.status, body };
637
+ }
638
+ function createWooCustomers(options) {
639
+ const newId = options?.newId ?? newWooId;
640
+ return {
641
+ async searchCustomers(context, query, options2) {
642
+ const q = query.trim();
643
+ if (!q) return [];
644
+ const params = new URLSearchParams({ search: q, role: "customer", per_page: String(Math.max(1, Math.min(50, options2?.limit ?? 20))) });
645
+ const { body } = await customerRequest(context, `customers?${params}`, { method: "GET" });
646
+ if (!Array.isArray(body)) throw new CustomerServiceError("server", "unexpected response");
647
+ return body.map(toWooCustomer);
648
+ },
649
+ async getCustomer(context, id) {
650
+ if (!/^\d+$/.test(id)) return null;
651
+ const params = new URLSearchParams({ include: id, per_page: "1", role: "all" });
652
+ const { body } = await customerRequest(context, `customers?${params}`, { method: "GET" });
653
+ if (!Array.isArray(body)) throw new CustomerServiceError("server", "unexpected response");
654
+ const raw = body.find((row) => String(row.id) === id);
655
+ return raw ? toWooCustomer(raw) : null;
656
+ },
657
+ async createCustomer(context, input) {
658
+ const mutationId = newId();
659
+ const recordId = newId();
660
+ const fields = {
661
+ ...input.email ? { email: input.email } : {},
662
+ ...input.firstName ? { first_name: input.firstName } : {},
663
+ ...input.lastName ? { last_name: input.lastName } : {}
664
+ };
665
+ const { status, body } = await customerRequest(context, "push/customers", {
666
+ method: "POST",
667
+ headers: { "Content-Type": "application/json" },
668
+ body: JSON.stringify({
669
+ mutationId,
670
+ operation: "create",
671
+ collection: "customers",
672
+ recordId,
673
+ baseRevision: null,
674
+ payload: { ...fields, billing: {
675
+ ...fields,
676
+ ...input.phone ? { phone: input.phone } : {},
677
+ ...input.company ? { company: input.company } : {}
678
+ } }
679
+ })
680
+ });
681
+ if (status !== 200 && status !== 201 || !body?.document) throw new CustomerServiceError("server", "unexpected response");
682
+ return toWooCustomer(body.document);
683
+ },
684
+ async emailReceipt(context, orderId, email, options2) {
685
+ if (!/^[1-9]\d*$/.test(orderId)) throw new CustomerServiceError("invalid", "Invalid order ID");
686
+ const { status, body } = await customerRequest(context, `orders/${encodeURIComponent(orderId)}/email`, {
687
+ method: "POST",
688
+ headers: { "Content-Type": "application/json" },
689
+ body: JSON.stringify({ email, ...options2?.saveToBilling ? { save_to: "billing" } : {} })
690
+ });
691
+ if (status !== 200 || body?.success !== true) throw new CustomerServiceError("server", "unexpected response");
692
+ }
693
+ };
694
+ }
695
+
595
696
  // package.json
596
- var version = "3.0.4";
697
+ var version = "3.1.1";
597
698
 
598
699
  // src/commands/transport.ts
599
700
  import { minorUnitDigits, moneyFromDecimalString, moneyFromMajor as moneyFromMajor2 } from "@tallyui/core";
@@ -738,8 +839,11 @@ var WooMissingTokenError = class extends ConnectorUnauthorizedError2 {
738
839
  };
739
840
  function createWooCommerceConnector() {
740
841
  const catalogueFeed = createWooReconcileFeed();
842
+ const customers = createWooCustomers();
741
843
  return {
742
844
  ...wooConnectorParts,
845
+ // WCPOS wcpos/v2/customers, push/customers and orders/<id>/email.
846
+ ...customers,
743
847
  replication: {
744
848
  // One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
745
849
  // duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
@@ -809,6 +913,8 @@ export {
809
913
  WooTokenRefusedError,
810
914
  createWooCommandTransport,
811
915
  createWooCommerceConnector,
916
+ createWooCustomers,
917
+ toWooCustomer,
812
918
  toWooOrderPayload,
813
919
  wcposClientPart,
814
920
  wooCatalogueReconcile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-woocommerce",
3
- "version": "3.0.4",
3
+ "version": "3.1.1",
4
4
  "type": "module",
5
5
  "description": "WooCommerce connector for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -26,10 +26,10 @@
26
26
  "directory": "connectors/woocommerce"
27
27
  },
28
28
  "peerDependencies": {
29
- "@tallyui/core": "^3.0.4"
29
+ "@tallyui/core": "^3.1.1"
30
30
  },
31
31
  "devDependencies": {
32
- "@tallyui/database": "3.0.4",
32
+ "@tallyui/database": "3.1.1",
33
33
  "rxdb": "17.5.0"
34
34
  },
35
35
  "scripts": {
@@ -0,0 +1,107 @@
1
+ import { CustomerServiceError, type Customer, type CustomerInput, type SyncContext } from '@tallyui/core';
2
+ import { checkResponse } from './replication/products';
3
+
4
+ type WooCustomerRow = {
5
+ id: number | string;
6
+ first_name?: string | null;
7
+ last_name?: string | null;
8
+ email?: string | null;
9
+ billing?: {
10
+ first_name?: string | null; last_name?: string | null; email?: string | null;
11
+ phone?: string | null; company?: string | null;
12
+ } | null;
13
+ };
14
+
15
+ export function toWooCustomer(raw: WooCustomerRow): Customer {
16
+ const customer: Customer = { id: String(raw.id), name: String(raw.id) };
17
+ for (const [key, value] of [
18
+ ['firstName', raw.first_name || raw.billing?.first_name],
19
+ ['lastName', raw.last_name || raw.billing?.last_name],
20
+ ['email', raw.email || raw.billing?.email],
21
+ ['phone', raw.billing?.phone], ['company', raw.billing?.company],
22
+ ] as const) {
23
+ if (typeof value === 'string' && value.length > 0) customer[key] = value;
24
+ }
25
+ customer.name = [customer.firstName, customer.lastName].filter(Boolean).join(' ') || customer.company || customer.email || customer.id;
26
+ return customer;
27
+ }
28
+
29
+ function newWooId(): string {
30
+ if (!globalThis.crypto?.getRandomValues) throw new Error('connector-woocommerce: no random source');
31
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
32
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
33
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
34
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
35
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
36
+ }
37
+
38
+ async function customerRequest(context: SyncContext, route: string, init: RequestInit = {}) {
39
+ let response: Response;
40
+ try {
41
+ response = await fetch(`${context.baseUrl}/${route}`, {
42
+ ...init, headers: { ...init.headers, ...context.headers }, signal: context.signal,
43
+ });
44
+ } catch (error) {
45
+ throw new CustomerServiceError('network', error instanceof Error ? error.message : String(error));
46
+ }
47
+ if ([401, 403, 426].includes(response.status)) await checkResponse(response);
48
+ const body = await response.json().catch(() => undefined);
49
+ if (!response.ok) {
50
+ const invalid = [400, 404, 422].includes(response.status);
51
+ throw new CustomerServiceError(invalid ? 'invalid' : 'server', invalid ? body?.message ?? `HTTP ${response.status}` : `HTTP ${response.status}`);
52
+ }
53
+ return { status: response.status, body };
54
+ }
55
+
56
+ export function createWooCustomers(options?: { newId?: () => string }) {
57
+ const newId = options?.newId ?? newWooId;
58
+ return {
59
+ async searchCustomers(context: SyncContext, query: string, options?: { limit?: number }): Promise<Customer[]> {
60
+ const q = query.trim();
61
+ if (!q) return [];
62
+ const params = new URLSearchParams({ search: q, role: 'customer', per_page: String(Math.max(1, Math.min(50, options?.limit ?? 20))) });
63
+ const { body } = await customerRequest(context, `customers?${params}`, { method: 'GET' });
64
+ if (!Array.isArray(body)) throw new CustomerServiceError('server', 'unexpected response');
65
+ return body.map(toWooCustomer);
66
+ },
67
+
68
+ async getCustomer(context: SyncContext, id: string): Promise<Customer | null> {
69
+ if (!/^\d+$/.test(id)) return null;
70
+ const params = new URLSearchParams({ include: id, per_page: '1', role: 'all' });
71
+ const { body } = await customerRequest(context, `customers?${params}`, { method: 'GET' });
72
+ if (!Array.isArray(body)) throw new CustomerServiceError('server', 'unexpected response');
73
+ const raw = body.find((row: WooCustomerRow) => String(row.id) === id);
74
+ return raw ? toWooCustomer(raw) : null;
75
+ },
76
+
77
+ async createCustomer(context: SyncContext, input: CustomerInput): Promise<Customer> {
78
+ const mutationId = newId();
79
+ const recordId = newId();
80
+ const fields = {
81
+ ...(input.email ? { email: input.email } : {}),
82
+ ...(input.firstName ? { first_name: input.firstName } : {}),
83
+ ...(input.lastName ? { last_name: input.lastName } : {}),
84
+ };
85
+ const { status, body } = await customerRequest(context, 'push/customers', {
86
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({
88
+ mutationId, operation: 'create', collection: 'customers', recordId, baseRevision: null,
89
+ payload: { ...fields, billing: {
90
+ ...fields, ...(input.phone ? { phone: input.phone } : {}), ...(input.company ? { company: input.company } : {}),
91
+ } },
92
+ }),
93
+ });
94
+ if ((status !== 200 && status !== 201) || !body?.document) throw new CustomerServiceError('server', 'unexpected response');
95
+ return toWooCustomer(body.document);
96
+ },
97
+
98
+ async emailReceipt(context: SyncContext, orderId: string, email: string, options?: { saveToBilling?: boolean }): Promise<void> {
99
+ if (!/^[1-9]\d*$/.test(orderId)) throw new CustomerServiceError('invalid', 'Invalid order ID');
100
+ const { status, body } = await customerRequest(context, `orders/${encodeURIComponent(orderId)}/email`, {
101
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({ email, ...(options?.saveToBilling ? { save_to: 'billing' } : {}) }),
103
+ });
104
+ if (status !== 200 || body?.success !== true) throw new CustomerServiceError('server', 'unexpected response');
105
+ },
106
+ };
107
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { wooProductSync } from './sync/products';
6
6
  import { wooProductReplication } from './replication/products';
7
7
  import { wooCatalogueReconcile } from './reconcile/catalogue';
8
8
  import { createWooReconcileFeed, MAX_IDS_PER_REQUEST } from './reconcile/feed';
9
+ import { createWooCustomers } from './customers';
9
10
  import { version } from '../package.json';
10
11
 
11
12
  /** One part of X-WCPOS-Client as WCPOS keeps it: lowercase [a-z0-9._-], at most 32 characters. */
@@ -41,8 +42,11 @@ export class WooMissingTokenError extends ConnectorUnauthorizedError {
41
42
  export function createWooCommerceConnector(): TallyConnector {
42
43
  // The catalogue reconcile's corrections reach `products` only through this pull adapter (#248).
43
44
  const catalogueFeed = createWooReconcileFeed();
45
+ const customers = createWooCustomers();
44
46
  return {
45
47
  ...wooConnectorParts,
48
+ // WCPOS wcpos/v2/customers, push/customers and orders/<id>/email.
49
+ ...customers,
46
50
  replication: {
47
51
  // One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
48
52
  // duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
@@ -115,6 +119,7 @@ const wooConnectorParts = {
115
119
  export const woocommerceConnector: TallyConnector = createWooCommerceConnector();
116
120
 
117
121
  // Re-export pieces for advanced usage
122
+ export { createWooCustomers, toWooCustomer } from './customers';
118
123
  export { createWooCommandTransport, toWooOrderPayload, type WooCommandTransportOptions } from './commands/transport';
119
124
  export { ConnectorUnauthorizedError } from '@tallyui/core';
120
125
  export { wooProductSchema } from './schemas/products';