@stacksjs/commerce 0.75.40 → 0.75.42
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/customers/store.d.ts +6 -0
- package/dist/imports/account-mapping.d.ts +44 -0
- package/dist/imports/account-types.d.ts +99 -0
- package/dist/imports/account-writer.d.ts +237 -0
- package/dist/imports/admin-http.d.ts +30 -0
- package/dist/imports/credentials.d.ts +44 -0
- package/dist/imports/http.d.ts +7 -1
- package/dist/imports/identity.d.ts +6 -1
- package/dist/imports/index.d.ts +28 -0
- package/dist/imports/shopify-admin.d.ts +95 -0
- package/dist/imports/shopware-admin.d.ts +89 -0
- package/dist/imports/woocommerce-admin.d.ts +66 -0
- package/dist/imports/woocommerce.d.ts +6 -0
- package/dist/index.js +5 -5
- package/package.json +12 -12
|
@@ -6,5 +6,11 @@ import type { Customer, ModelRow, NewModelData } from '@stacksjs/orm';
|
|
|
6
6
|
* @returns The newly created customer record
|
|
7
7
|
*/
|
|
8
8
|
export declare function store(data: NewCustomer): Promise<CustomerJsonResponse>;
|
|
9
|
+
/**
|
|
10
|
+
* The avatar a customer gets when nothing supplies one. `customers.avatar` is
|
|
11
|
+
* NOT NULL and the dashboard requires an http(s) URL there, so a row without
|
|
12
|
+
* one would break the customer list. The customer importer uses it too.
|
|
13
|
+
*/
|
|
14
|
+
export declare const DEFAULT_CUSTOMER_AVATAR: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&fit=crop&q=60';
|
|
9
15
|
declare type CustomerJsonResponse = ModelRow<typeof Customer>;
|
|
10
16
|
declare type NewCustomer = NewModelData<typeof Customer>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CatalogWarning } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* An email as the deduplication key: trimmed and lowercased, or null when it
|
|
4
|
+
* is not an address at all. The domain part is case-insensitive by spec, and
|
|
5
|
+
* every platform treats the local part that way in practice, so `Bob@Shop.com`
|
|
6
|
+
* and `bob@shop.com ` are one customer.
|
|
7
|
+
*/
|
|
8
|
+
export declare function normalizeEmail(input: unknown): string | null;
|
|
9
|
+
/** A timestamp as ISO 8601 UTC, or null when it is missing or unreadable. */
|
|
10
|
+
export declare function isoTimestamp(input: unknown): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* WooCommerce's `*_gmt` fields are UTC without a zone designator
|
|
13
|
+
* (`2026-09-14T09:12:44`), which `Date` would read as local time.
|
|
14
|
+
*/
|
|
15
|
+
export declare function gmtTimestamp(input: unknown): string | null;
|
|
16
|
+
/** `first last`, collapsed, or null when both are empty. */
|
|
17
|
+
export declare function personName(...parts: unknown[]): string | null;
|
|
18
|
+
/** A phone number as text, capped at the `customers.phone` length. */
|
|
19
|
+
export declare function phoneOf(...candidates: unknown[]): string | null;
|
|
20
|
+
/** Address parts joined into the single line `orders.delivery_address` holds. */
|
|
21
|
+
export declare function addressLine(...parts: unknown[]): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* A signed decimal (a string, or a JSON number) as signed integer minor units.
|
|
24
|
+
*
|
|
25
|
+
* `decimalToMinor` accepts magnitudes only, so the sign is split off first.
|
|
26
|
+
* A float is never multiplied: a JSON number goes through the decimal string
|
|
27
|
+
* it was serialized as (`numberToMinor`).
|
|
28
|
+
*/
|
|
29
|
+
export declare function signedMinor(value: unknown, exponent: number): number | null;
|
|
30
|
+
/**
|
|
31
|
+
* An amount that must not be negative, as minor units, or `fallback` with a
|
|
32
|
+
* warning when it is missing, malformed or negative. The order tables hold
|
|
33
|
+
* every amount as a non-negative integer.
|
|
34
|
+
*/
|
|
35
|
+
export declare function amountOrZero(value: unknown, exponent: number, label: string, externalId: string, warnings: CatalogWarning[], fallback?: number): number;
|
|
36
|
+
/**
|
|
37
|
+
* A line total spread over its quantity, in minor units, rounded half up.
|
|
38
|
+
* Integer arithmetic only: `(2 * total + quantity) / (2 * quantity)`, floored.
|
|
39
|
+
*/
|
|
40
|
+
export declare function unitFromTotal(totalMinor: number, quantity: number): number;
|
|
41
|
+
/** A positive whole quantity, or null. */
|
|
42
|
+
export declare function quantityOf(value: unknown): number | null;
|
|
43
|
+
/** A three-letter ISO 4217 code, uppercased, or null. */
|
|
44
|
+
export declare function currencyCode(value: unknown): string | null;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CatalogSourceName, CatalogWarning, FetchLike } from './types';
|
|
2
|
+
import type { OrderStatus } from '../orders/events';
|
|
3
|
+
/**
|
|
4
|
+
* The normalized customers and orders the credentialed import maps into
|
|
5
|
+
* (stacksjs/stacks#2853).
|
|
6
|
+
*
|
|
7
|
+
* The catalog import reads public storefront APIs. Customers and orders are
|
|
8
|
+
* only on each platform's admin API, behind credentials: a Shopify Admin API
|
|
9
|
+
* access token, WooCommerce REST API consumer keys, or a Shopware Admin API
|
|
10
|
+
* integration. Each source is an adapter that maps its own payloads into these
|
|
11
|
+
* records in pure functions, so the writer never sees a platform's shape and
|
|
12
|
+
* every mapping is testable from recorded JSON.
|
|
13
|
+
*
|
|
14
|
+
* Money is integer minor units of the record's own currency throughout.
|
|
15
|
+
* Nothing here has a field for a password hash or payment card data, and no
|
|
16
|
+
* adapter requests one: what is not mapped cannot be written.
|
|
17
|
+
*/
|
|
18
|
+
/** A customer as every source maps it. */
|
|
19
|
+
export declare interface AccountCustomer {
|
|
20
|
+
source: CatalogSourceName
|
|
21
|
+
externalId: string | null
|
|
22
|
+
email: string
|
|
23
|
+
name: string
|
|
24
|
+
phone: string | null
|
|
25
|
+
active: boolean | null
|
|
26
|
+
totalSpentMinor: number | null
|
|
27
|
+
currency: string | null
|
|
28
|
+
lastOrderAt: string | null
|
|
29
|
+
avatarUrl: string | null
|
|
30
|
+
createdAt: string | null
|
|
31
|
+
}
|
|
32
|
+
/** Who placed an order, as the order itself records it. */
|
|
33
|
+
export declare interface AccountOrderCustomer {
|
|
34
|
+
externalId: string | null
|
|
35
|
+
email: string | null
|
|
36
|
+
name: string | null
|
|
37
|
+
phone: string | null
|
|
38
|
+
}
|
|
39
|
+
/** One purchased line of an order. */
|
|
40
|
+
export declare interface AccountOrderLine {
|
|
41
|
+
externalId: string
|
|
42
|
+
productExternalId: string | null
|
|
43
|
+
variantExternalId: string | null
|
|
44
|
+
name: string
|
|
45
|
+
sku: string | null
|
|
46
|
+
quantity: number
|
|
47
|
+
unitPriceMinor: number
|
|
48
|
+
}
|
|
49
|
+
export declare interface AccountOrder {
|
|
50
|
+
source: CatalogSourceName
|
|
51
|
+
externalId: string
|
|
52
|
+
number: string | null
|
|
53
|
+
currency: string
|
|
54
|
+
status: OrderStatus
|
|
55
|
+
sourceStatus: string
|
|
56
|
+
totalMinor: number
|
|
57
|
+
taxMinor: number
|
|
58
|
+
discountMinor: number
|
|
59
|
+
shippingMinor: number
|
|
60
|
+
tipMinor: number
|
|
61
|
+
customer: AccountOrderCustomer | null
|
|
62
|
+
shippingAddress: string | null
|
|
63
|
+
note: string | null
|
|
64
|
+
placedAt: string | null
|
|
65
|
+
lines: AccountOrderLine[]
|
|
66
|
+
refundedMinor?: number
|
|
67
|
+
}
|
|
68
|
+
/** A source payload mapped to a record (or skipped), with what the mapping noticed. */
|
|
69
|
+
export declare interface MappedAccountRecord<T> {
|
|
70
|
+
record: T | null
|
|
71
|
+
warnings: CatalogWarning[]
|
|
72
|
+
}
|
|
73
|
+
/** One page of a source's customers or orders, already normalized. */
|
|
74
|
+
export declare interface AccountPage<T> {
|
|
75
|
+
url: string
|
|
76
|
+
items: T[]
|
|
77
|
+
warnings: CatalogWarning[]
|
|
78
|
+
}
|
|
79
|
+
export declare interface AccountFetchOptions {
|
|
80
|
+
credentials: AccountCredentials
|
|
81
|
+
limit?: number
|
|
82
|
+
fetch?: FetchLike
|
|
83
|
+
currency?: string
|
|
84
|
+
adminUrl?: string
|
|
85
|
+
sleep?: (ms: number) => Promise<void>
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A source of customers and orders. Implement this to add a platform: map its
|
|
89
|
+
* payloads in pure functions, and page through its admin API here.
|
|
90
|
+
*/
|
|
91
|
+
export declare interface AccountAdapter {
|
|
92
|
+
name: CatalogSourceName
|
|
93
|
+
customers: (storeUrl: string, options: AccountFetchOptions) => AsyncIterable<AccountPage<AccountCustomer>>
|
|
94
|
+
orders: (storeUrl: string, options: AccountFetchOptions) => AsyncIterable<AccountPage<AccountOrder>>
|
|
95
|
+
}
|
|
96
|
+
/** Credentials for one source. Read from env only, never from a flag. */
|
|
97
|
+
export type AccountCredentials = | { source: 'shopify', accessToken: string }
|
|
98
|
+
| { source: 'woocommerce', consumerKey: string, consumerSecret: string }
|
|
99
|
+
| { source: 'shopware', clientId: string, clientSecret: string }
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { AccountCustomer, AccountOrder, AccountOrderLine } from './account-types';
|
|
2
|
+
import type { CatalogWarning } from './types';
|
|
3
|
+
import type { ImportAction, ImportCounts } from './writer';
|
|
4
|
+
/** The uuid a customer gets when an import creates it: from the source id, else from the email. */
|
|
5
|
+
export declare function customerUuid(source: string, host: string, externalId: string | null, email: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* The account uuid a row found by email should take, or null to leave it.
|
|
8
|
+
*
|
|
9
|
+
* Only a uuid this importer derived from that same email (a guest, or a
|
|
10
|
+
* shopper first seen on an order) is replaced: it names no source record, so
|
|
11
|
+
* once the source has an account for the address, the account's id is the
|
|
12
|
+
* better key. A row created any other way (checkout, the dashboard, another
|
|
13
|
+
* store) keeps its uuid, which something else may already refer to.
|
|
14
|
+
*/
|
|
15
|
+
export declare function promotedCustomerUuid(source: string, host: string, existingUuid: string | null, externalId: string | null, email: string): string | null;
|
|
16
|
+
/** The `customers` row for an imported customer. Pure. */
|
|
17
|
+
export declare function customerRow(customer: AccountCustomer, uuid: string, now: string, mode: 'create' | 'update'): CustomerRow;
|
|
18
|
+
/** `Name (SKU X)`: what `order_items.special_instructions` keeps of a line. */
|
|
19
|
+
export declare function lineLabel(line: AccountOrderLine): string;
|
|
20
|
+
/** The `orders` row for an imported order. Pure. */
|
|
21
|
+
export declare function orderRow(order: AccountOrder, host: string, customerId: number | null, now: string): OrderRow;
|
|
22
|
+
/**
|
|
23
|
+
* Fetch a store's customers through `adapter` and upsert them via
|
|
24
|
+
* `repository`, deduplicated by email. Idempotent: a second run over an
|
|
25
|
+
* unchanged store updates every row and creates none.
|
|
26
|
+
*/
|
|
27
|
+
export declare function importCustomers(options: ImportCustomersOptions): Promise<CustomerImportResult>;
|
|
28
|
+
/**
|
|
29
|
+
* Fetch a store's orders through `adapter` and upsert them, with their lines,
|
|
30
|
+
* via `repository`. Each order's customer is the Stacks customer with its
|
|
31
|
+
* email (created from the order when there is none); each line links to the
|
|
32
|
+
* product the catalog import wrote, when it exists.
|
|
33
|
+
*/
|
|
34
|
+
export declare function importOrders(options: ImportOrdersOptions): Promise<OrderImportResult>;
|
|
35
|
+
/**
|
|
36
|
+
* A repository over the commerce tables, via the query builder.
|
|
37
|
+
*
|
|
38
|
+
* Inserts are read back by `uuid`, which works on every dialect. Emails are
|
|
39
|
+
* compared with `LOWER()` on both sides, because rows created by checkout or
|
|
40
|
+
* the dashboard keep whatever case the shopper typed.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createDatabaseAccountRepository(connection?: any): AccountRepository;
|
|
43
|
+
/**
|
|
44
|
+
* A repository that keeps rows in memory. Pass the catalog memory
|
|
45
|
+
* repository's `tables` to share its products and variants, as a real
|
|
46
|
+
* database would.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createMemoryAccountRepository(shared?: { tables: Record<string, Array<Record<string, any>>> }): AccountRepository & { tables: Record<string, Array<Record<string, any>>> };
|
|
49
|
+
/**
|
|
50
|
+
* Reads through to `inner`, writes nothing. What `--dry-run` uses, so its
|
|
51
|
+
* report says "create" or "update" exactly as a real run would.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createReadOnlyAccountRepository(inner: AccountRepository): AccountRepository;
|
|
54
|
+
/**
|
|
55
|
+
* Writing imported customers and orders into the built-in commerce tables.
|
|
56
|
+
*
|
|
57
|
+
* Where each field lands, given the columns those tables actually have:
|
|
58
|
+
*
|
|
59
|
+
* | Source | Column |
|
|
60
|
+
* |---------------------------------|-----------------------------------------------------|
|
|
61
|
+
* | customer email (trim, lowercase)| `customers.email`, the deduplication key |
|
|
62
|
+
* | customer id + store host | `customers.uuid` on create (UUIDv5, `identity.ts`) |
|
|
63
|
+
* | first + last name | `customers.name` (the email when there is none) |
|
|
64
|
+
* | phone | `customers.phone` |
|
|
65
|
+
* | lifetime spend, when reported | `customers.total_spent`, integer minor units |
|
|
66
|
+
* | last order date, when reported | `customers.last_order`, and moved forward (never |
|
|
67
|
+
* | or placed by an imported order| back) to the latest order the import links to it |
|
|
68
|
+
* | account disabled (Shopware) | `customers.status` `Inactive` |
|
|
69
|
+
* | order id + store host | `orders.uuid` (UUIDv5) |
|
|
70
|
+
* | mapped status | `orders.status` (the `OrderStatus` vocabulary) |
|
|
71
|
+
* | currency | `orders.currency`, per order |
|
|
72
|
+
* | total / tax / discount / shipping / tip | `orders.total_amount` / `tax_amount` / `discount_amount` / `delivery_fee` / `tip_amount`, minor units |
|
|
73
|
+
* | shipping address, one line | `orders.delivery_address` |
|
|
74
|
+
* | customer note | `orders.special_instructions` |
|
|
75
|
+
* | placed at | `orders.created_at` |
|
|
76
|
+
* | each line | `order_items`: quantity, unit `price`, `product_id` |
|
|
77
|
+
* | line label and SKU | `order_items.special_instructions` |
|
|
78
|
+
*
|
|
79
|
+
* Customers are deduplicated by email, case-insensitively. A customer is
|
|
80
|
+
* matched first by the uuid an earlier run derived from its source id (so a
|
|
81
|
+
* changed email updates the row instead of adding one), then by email, so a
|
|
82
|
+
* customer who already exists in Stacks (a checkout, another store) is updated
|
|
83
|
+
* rather than duplicated. A row an earlier run created for a guest (its uuid
|
|
84
|
+
* derived from the email) takes the account's uuid once the shopper has an
|
|
85
|
+
* account at the source, so a later change of address there still finds it.
|
|
86
|
+
* Nothing password- or card-shaped is ever written: the normalized records
|
|
87
|
+
* have no field for either.
|
|
88
|
+
*
|
|
89
|
+
* Orders are matched on their derived uuid. A re-run updates the order and
|
|
90
|
+
* replaces its lines, since `order_items` has no identity column of its own;
|
|
91
|
+
* the lines of an imported order belong to the source. A line is linked to the
|
|
92
|
+
* product the catalog import wrote, through the same deterministic identities
|
|
93
|
+
* (`catalogUuid`). A line whose product is not in Stacks yet is kept, with no
|
|
94
|
+
* `product_id`, its label and price intact, and reported: import the catalog,
|
|
95
|
+
* re-run the order import, and the lines link. A line that names no product
|
|
96
|
+
* at the source (a fee, a custom item, a product deleted since) is kept the
|
|
97
|
+
* same way and counted apart, since no catalog import will ever link it.
|
|
98
|
+
*
|
|
99
|
+
* Writes go through the query builder, not the models, so importing a store's
|
|
100
|
+
* history fires no `order:created` events or emails to its customers.
|
|
101
|
+
*/
|
|
102
|
+
export declare interface CustomerRow {
|
|
103
|
+
uuid?: string
|
|
104
|
+
name: string
|
|
105
|
+
email?: string
|
|
106
|
+
phone?: string | null
|
|
107
|
+
total_spent?: number
|
|
108
|
+
last_order?: string | null
|
|
109
|
+
status?: 'Active' | 'Inactive'
|
|
110
|
+
avatar?: string
|
|
111
|
+
created_at?: string
|
|
112
|
+
updated_at: string
|
|
113
|
+
}
|
|
114
|
+
export declare interface OrderRow {
|
|
115
|
+
uuid: string
|
|
116
|
+
status: string
|
|
117
|
+
total_amount: number
|
|
118
|
+
currency: string
|
|
119
|
+
tax_amount: number
|
|
120
|
+
discount_amount: number
|
|
121
|
+
delivery_fee: number
|
|
122
|
+
tip_amount: number
|
|
123
|
+
order_type: string
|
|
124
|
+
delivery_address: string | null
|
|
125
|
+
special_instructions: string | null
|
|
126
|
+
customer_id?: number | null
|
|
127
|
+
created_at: string
|
|
128
|
+
updated_at: string
|
|
129
|
+
}
|
|
130
|
+
export declare interface OrderItemRow {
|
|
131
|
+
order_id: number
|
|
132
|
+
product_id: number | null
|
|
133
|
+
quantity: number
|
|
134
|
+
price: number
|
|
135
|
+
special_instructions: string
|
|
136
|
+
created_at: string
|
|
137
|
+
updated_at: string
|
|
138
|
+
}
|
|
139
|
+
export declare interface ExistingCustomer {
|
|
140
|
+
id: number
|
|
141
|
+
uuid: string | null
|
|
142
|
+
email: string
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Where the importer reads and writes customers and orders. The database
|
|
146
|
+
* implementation is below; tests and `--dry-run` use the in-memory and
|
|
147
|
+
* read-only ones. Every `find*` takes a whole page's keys at once.
|
|
148
|
+
*/
|
|
149
|
+
export declare interface AccountRepository {
|
|
150
|
+
findCustomers: (query: { uuids: string[], emails: string[] }) => Promise<ExistingCustomer[]>
|
|
151
|
+
createCustomer: (row: CustomerRow) => Promise<number>
|
|
152
|
+
updateCustomer: (id: number, row: CustomerUpdate) => Promise<void>
|
|
153
|
+
advanceLastOrder?: (id: number, at: string) => Promise<void>
|
|
154
|
+
findOrders: (uuids: string[]) => Promise<Map<string, number>>
|
|
155
|
+
createOrder: (row: OrderRow) => Promise<number>
|
|
156
|
+
updateOrder: (id: number, row: Omit<OrderRow, 'uuid'>) => Promise<void>
|
|
157
|
+
replaceOrderItems: (orderId: number, rows: OrderItemRow[]) => Promise<void>
|
|
158
|
+
findProducts: (uuids: string[]) => Promise<Map<string, number>>
|
|
159
|
+
findVariantProducts: (uuids: string[]) => Promise<Map<string, number>>
|
|
160
|
+
transaction?: <T>(work: (repository: AccountRepository) => Promise<T>) => Promise<T>
|
|
161
|
+
}
|
|
162
|
+
export declare interface ImportedCustomer {
|
|
163
|
+
action: ImportAction
|
|
164
|
+
externalId: string | null
|
|
165
|
+
email: string
|
|
166
|
+
name: string
|
|
167
|
+
matchedBy: 'uuid' | 'email' | null
|
|
168
|
+
}
|
|
169
|
+
export declare interface ImportedOrder {
|
|
170
|
+
action: ImportAction
|
|
171
|
+
externalId: string
|
|
172
|
+
number: string | null
|
|
173
|
+
status: string
|
|
174
|
+
sourceStatus: string
|
|
175
|
+
totalMinor: number
|
|
176
|
+
currency: string
|
|
177
|
+
lines: number
|
|
178
|
+
unlinkedLines: number
|
|
179
|
+
productlessLines: number
|
|
180
|
+
refundedMinor: number
|
|
181
|
+
customerEmail: string | null
|
|
182
|
+
}
|
|
183
|
+
export declare interface CustomerImportResult {
|
|
184
|
+
source: string
|
|
185
|
+
storeUrl: string
|
|
186
|
+
dryRun: boolean
|
|
187
|
+
pages: string[]
|
|
188
|
+
customers: ImportedCustomer[]
|
|
189
|
+
counts: {
|
|
190
|
+
customers: ImportCounts
|
|
191
|
+
merged: number
|
|
192
|
+
}
|
|
193
|
+
warnings: CatalogWarning[]
|
|
194
|
+
}
|
|
195
|
+
export declare interface OrderImportResult {
|
|
196
|
+
source: string
|
|
197
|
+
storeUrl: string
|
|
198
|
+
dryRun: boolean
|
|
199
|
+
pages: string[]
|
|
200
|
+
orders: ImportedOrder[]
|
|
201
|
+
counts: {
|
|
202
|
+
orders: ImportCounts
|
|
203
|
+
customers: ImportCounts
|
|
204
|
+
lines: { linked: number, unlinked: number, productless: number }
|
|
205
|
+
duplicates: number
|
|
206
|
+
partlyRefunded: number
|
|
207
|
+
}
|
|
208
|
+
currencies: string[]
|
|
209
|
+
warnings: CatalogWarning[]
|
|
210
|
+
}
|
|
211
|
+
declare interface AccountImportOptions extends AccountFetchOptions {
|
|
212
|
+
adapter: AccountAdapter
|
|
213
|
+
storeUrl: string
|
|
214
|
+
repository: AccountRepository
|
|
215
|
+
dryRun?: boolean
|
|
216
|
+
now?: () => Date
|
|
217
|
+
}
|
|
218
|
+
export declare interface ImportCustomersOptions extends AccountImportOptions {
|
|
219
|
+
onCustomer?: (customer: ImportedCustomer) => void
|
|
220
|
+
}
|
|
221
|
+
export declare interface ImportOrdersOptions extends AccountImportOptions {
|
|
222
|
+
onOrder?: (order: ImportedOrder) => void
|
|
223
|
+
}
|
|
224
|
+
/** The columns an update writes; anything left out keeps its value. */
|
|
225
|
+
export type CustomerUpdate = Partial<CustomerRow> & { updated_at: string }
|
|
226
|
+
/**
|
|
227
|
+
* Resolves customers to row ids by uuid, then by email, across a whole run.
|
|
228
|
+
* Lookups are batched per page; rows created during the run are remembered so
|
|
229
|
+
* the next order for the same email finds them without a query.
|
|
230
|
+
*/
|
|
231
|
+
declare class CustomerIndex {
|
|
232
|
+
load(repository: AccountRepository, keys: Array<{ uuid: string | null, email: string | null }>): Promise<void>;
|
|
233
|
+
remember(id: number, uuid: string | null, email: string | null): void;
|
|
234
|
+
find(uuid: string | null, email: string | null): { id: number, matchedBy: 'uuid' | 'email' } | null;
|
|
235
|
+
uuidOf(id: number): string | null;
|
|
236
|
+
emailTakenByOther(email: string, id: number): boolean;
|
|
237
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CatalogFetchError } from './http';
|
|
2
|
+
import type { FetchLike } from './types';
|
|
3
|
+
import type { JsonRequest, JsonResponse } from './http';
|
|
4
|
+
/** One admin API call, with rate-limit retries and credential-aware errors. */
|
|
5
|
+
export declare function adminRequest<T>(url: string, options: AdminRequestOptions): Promise<JsonResponse<T>>;
|
|
6
|
+
/**
|
|
7
|
+
* Authenticated requests to a platform's admin API.
|
|
8
|
+
*
|
|
9
|
+
* On top of `fetchJson` (which names the failing URL), this:
|
|
10
|
+
*
|
|
11
|
+
* - waits out a 429 for as long as `Retry-After` asks (Shopify sends it on
|
|
12
|
+
* every throttled call; its REST leaky bucket refills at 2 calls a second),
|
|
13
|
+
* then retries, up to `MAX_RETRIES` times;
|
|
14
|
+
* - turns a 401 or 403 into a message about the credential, saying which
|
|
15
|
+
* variables to check and where to create them, without ever repeating a
|
|
16
|
+
* secret: every message is scrubbed of the credential values, including
|
|
17
|
+
* any a server echoed back in its error body.
|
|
18
|
+
*/
|
|
19
|
+
export declare const MAX_RETRIES: 5;
|
|
20
|
+
export declare const defaultSleep: (ms: number) => Promise<void>;
|
|
21
|
+
export declare interface AdminRequestOptions extends JsonRequest {
|
|
22
|
+
fetch: FetchLike
|
|
23
|
+
secrets: string[]
|
|
24
|
+
credentialHelp: string
|
|
25
|
+
hint?: string
|
|
26
|
+
sleep?: (ms: number) => Promise<void>
|
|
27
|
+
}
|
|
28
|
+
export declare class AdminCredentialError extends CatalogFetchError {
|
|
29
|
+
constructor(url: string, message: string, status: number);
|
|
30
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AccountCredentials } from './account-types';
|
|
2
|
+
/**
|
|
3
|
+
* The credentials `source` needs, from `env`, or a `MissingCredentialsError`
|
|
4
|
+
* naming every variable that is unset and where to create them. The values
|
|
5
|
+
* themselves never appear in a message.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readAccountCredentials(source: string, env: Env): AccountCredentials;
|
|
8
|
+
/** Every secret value in `credentials`, for scrubbing from messages. */
|
|
9
|
+
export declare function credentialSecrets(credentials: AccountCredentials): string[];
|
|
10
|
+
/** `text` with every secret replaced, so an error never repeats a credential, even one a server echoed. */
|
|
11
|
+
export declare function redactSecrets(text: string, secrets: string[]): string;
|
|
12
|
+
/**
|
|
13
|
+
* Admin API credentials for the customer and order import, from env only.
|
|
14
|
+
*
|
|
15
|
+
* A credential passed as a command-line flag lands in shell history, in `ps`
|
|
16
|
+
* output for every user on the machine, and in CI logs that echo commands.
|
|
17
|
+
* There is deliberately no flag for any of these: the operator exports them,
|
|
18
|
+
* or puts them in `.env`, which `buddy` loads.
|
|
19
|
+
*
|
|
20
|
+
* Every check here runs before the first request, so a missing credential
|
|
21
|
+
* fails with the variable names and where to create them, never with a 401
|
|
22
|
+
* halfway through an import.
|
|
23
|
+
*/
|
|
24
|
+
export declare const ACCOUNT_CREDENTIAL_ENV: {
|
|
25
|
+
shopify: readonly ['SHOPIFY_ADMIN_TOKEN'];
|
|
26
|
+
woocommerce: readonly ['WOOCOMMERCE_CONSUMER_KEY', 'WOOCOMMERCE_CONSUMER_SECRET'];
|
|
27
|
+
shopware: readonly ['SHOPWARE_CLIENT_ID', 'SHOPWARE_CLIENT_SECRET']
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Where each platform issues the credentials, for every message that asks for them.
|
|
31
|
+
* @defaultValue
|
|
32
|
+
* ```ts
|
|
33
|
+
* {
|
|
34
|
+
* shopify: 'Create a custom app for the store (Shopify admin > Settings > Apps > Develop apps, or the Shopify Dev Dashboard), give it the Admin API scopes read_customers and read_orders (plus read_all_orders for orders older than 60 days), install it, and copy its Admin API access token (shpat_...).',
|
|
35
|
+
* woocommerce: 'Create a key in WordPress admin > WooCommerce > Settings > Advanced > REST API > Add key, with Read permission, for a user who can manage WooCommerce. The key starts with ck_ and the secret with cs_.',
|
|
36
|
+
* shopware: 'Create an integration in the Shopware Administration > Settings > System > Integrations, with a role that can read customers and orders. Its access key id (SWIA...) is the client id and its secret access key is the client secret.'
|
|
37
|
+
* }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare const ACCOUNT_CREDENTIAL_HELP: Record<keyof typeof ACCOUNT_CREDENTIAL_ENV, string>;
|
|
41
|
+
declare type Env = Record<string, string | undefined>;
|
|
42
|
+
export declare class MissingCredentialsError extends Error {
|
|
43
|
+
constructor(message: string);
|
|
44
|
+
}
|
package/dist/imports/http.d.ts
CHANGED
|
@@ -9,6 +9,11 @@ import type { FetchLike } from './types';
|
|
|
9
9
|
export declare function normalizeStoreUrl(input: string): string;
|
|
10
10
|
/** The lowercased host of a normalized store URL, used to scope identities. */
|
|
11
11
|
export declare function storeHost(storeUrl: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* `Retry-After` as seconds. Shopify sends a decimal (`"2.0"`); the HTTP spec
|
|
14
|
+
* also allows an HTTP date. Anything unreadable is undefined, not zero.
|
|
15
|
+
*/
|
|
16
|
+
export declare function retryAfterSeconds(header: string | null | undefined, now?: () => number): number | undefined;
|
|
12
17
|
/** Fetch a URL and parse it as JSON (GET unless `request` says otherwise), or throw a `CatalogFetchError` naming it. */
|
|
13
18
|
export declare function fetchJson<T = unknown>(url: string, fetcher?: FetchLike, hint?: string, request?: JsonRequest): Promise<JsonResponse<T>>;
|
|
14
19
|
export declare interface JsonResponse<T> {
|
|
@@ -32,5 +37,6 @@ export declare class CatalogFetchError extends Error {
|
|
|
32
37
|
public readonly url: string;
|
|
33
38
|
public readonly status?: number;
|
|
34
39
|
public readonly body?: string;
|
|
35
|
-
|
|
40
|
+
public readonly retryAfter?: number;
|
|
41
|
+
constructor(url: string, message: string, status?: number, body?: string, retryAfter?: number);
|
|
36
42
|
}
|
|
@@ -19,4 +19,9 @@ export declare function catalogUuid(source: string, host: string, kind: CatalogR
|
|
|
19
19
|
*/
|
|
20
20
|
/** Namespace for every UUID the catalog importer derives. Never change it. */
|
|
21
21
|
export declare const CATALOG_IMPORT_NAMESPACE: '5d0c8a3e-6f1b-4c2a-9e47-b18f3d2a6c90';
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* What an identity names. `customer` and `order` come from the credentialed
|
|
24
|
+
* import (`accounts.ts`), and share the namespace so an order's line items
|
|
25
|
+
* resolve to the very `product` and `variant` uuids the catalog import wrote.
|
|
26
|
+
*/
|
|
27
|
+
export type CatalogRecordKind = 'product' | 'variant' | 'customer' | 'order';
|
package/dist/imports/index.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
+
import type { AccountAdapter } from './account-types';
|
|
1
2
|
import type { CatalogAdapter } from './types';
|
|
3
|
+
export type {
|
|
4
|
+
AccountAdapter,
|
|
5
|
+
AccountCredentials,
|
|
6
|
+
AccountCustomer,
|
|
7
|
+
AccountFetchOptions,
|
|
8
|
+
AccountOrder,
|
|
9
|
+
AccountOrderCustomer,
|
|
10
|
+
AccountOrderLine,
|
|
11
|
+
AccountPage,
|
|
12
|
+
MappedAccountRecord,
|
|
13
|
+
} from './account-types';
|
|
2
14
|
export type {
|
|
3
15
|
CatalogAdapter,
|
|
4
16
|
CatalogCategory,
|
|
@@ -14,6 +26,8 @@ export type {
|
|
|
14
26
|
} from './types';
|
|
15
27
|
/** The adapter registered under `name`, case-insensitively. */
|
|
16
28
|
export declare function catalogAdapter(name: string): CatalogAdapter | undefined;
|
|
29
|
+
/** The customer and order adapter registered under `name`, case-insensitively. */
|
|
30
|
+
export declare function accountAdapter(name: string): AccountAdapter | undefined;
|
|
17
31
|
/**
|
|
18
32
|
* Catalog import from other commerce platforms (stacksjs/stacks#1319, #1320,
|
|
19
33
|
* #1321).
|
|
@@ -25,13 +39,27 @@ export declare function catalogAdapter(name: string): CatalogAdapter | undefined
|
|
|
25
39
|
*
|
|
26
40
|
* Adding a platform means writing a `CatalogAdapter` (a pure payload mapper
|
|
27
41
|
* plus a pager) and registering it in `catalogAdapters`.
|
|
42
|
+
*
|
|
43
|
+
* Customers and orders (stacksjs/stacks#2853) come from each platform's admin
|
|
44
|
+
* API instead, with credentials read from env only (`credentials.ts`):
|
|
45
|
+
* `buddy commerce:import <store-url> --from <platform> --customers --orders`.
|
|
46
|
+
* Those adapters are `AccountAdapter`s, registered in `accountAdapters`, and
|
|
47
|
+
* written by `importCustomers` / `importOrders` (`account-writer.ts`).
|
|
28
48
|
*/
|
|
29
49
|
export declare const catalogAdapters: Readonly<Record<string, CatalogAdapter>>;
|
|
50
|
+
export declare const accountAdapters: Readonly<Record<string, AccountAdapter>>;
|
|
51
|
+
export * from './account-mapping';
|
|
52
|
+
export * from './account-writer';
|
|
53
|
+
export * from './admin-http';
|
|
54
|
+
export * from './credentials';
|
|
30
55
|
export * from './http';
|
|
31
56
|
export * from './identity';
|
|
32
57
|
export * from './money';
|
|
33
58
|
export * from './shopify';
|
|
59
|
+
export * from './shopify-admin';
|
|
34
60
|
export * from './shopware';
|
|
61
|
+
export * from './shopware-admin';
|
|
35
62
|
export * from './text';
|
|
36
63
|
export * from './woocommerce';
|
|
64
|
+
export * from './woocommerce-admin';
|
|
37
65
|
export * from './writer';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { AccountAdapter, AccountCustomer, AccountOrder, MappedAccountRecord } from './account-types';
|
|
2
|
+
import type { OrderStatus } from '../orders/events';
|
|
3
|
+
/** One Admin API customer as a normalized customer, or null with the reason. Pure. */
|
|
4
|
+
export declare function mapShopifyCustomer(raw: ShopifyCustomerPayload): MappedAccountRecord<AccountCustomer>;
|
|
5
|
+
/**
|
|
6
|
+
* The Stacks status for a Shopify order, and whether Shopify's combination
|
|
7
|
+
* was one this knows. Shopify has no "delivered": a fulfilled order shipped.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shopifyOrderStatus(raw: Pick<ShopifyOrderPayload, 'financial_status' | 'fulfillment_status' | 'cancelled_at'>): { status: OrderStatus, mapped: boolean };
|
|
10
|
+
/** One Admin API order as a normalized order, or null with the reason. Pure. */
|
|
11
|
+
export declare function mapShopifyOrder(raw: ShopifyOrderPayload, context?: { currency?: string }): MappedAccountRecord<AccountOrder>;
|
|
12
|
+
/** The `page_info` cursor of the `rel="next"` entry in a `Link` header, or null on the last page. */
|
|
13
|
+
export declare function nextPageInfo(link: string | null | undefined): string | null;
|
|
14
|
+
/** The admin API base: `adminUrl` when given (a custom domain's `.myshopify.com`), else the store URL. HTTPS only. */
|
|
15
|
+
export declare function shopifyAdminBase(storeUrl: string, adminUrl?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Shopify customers and orders, through the Admin REST API.
|
|
18
|
+
*
|
|
19
|
+
* Authenticated with a custom app's Admin API access token in the
|
|
20
|
+
* `X-Shopify-Access-Token` header (from `SHOPIFY_ADMIN_TOKEN`). Pages with
|
|
21
|
+
* cursor pagination: each response's `Link` header carries the next page's
|
|
22
|
+
* `page_info`, and a request with `page_info` may only add `limit` and
|
|
23
|
+
* `fields`, so every follow-up URL is rebuilt from those three.
|
|
24
|
+
*
|
|
25
|
+
* `fields` keeps each response to what the mappers read. Nothing requested
|
|
26
|
+
* carries a password (the Admin API never exposes one) or payment card data
|
|
27
|
+
* (`payment_details` and transactions are not requested).
|
|
28
|
+
*
|
|
29
|
+
* Shopify marked the REST Admin API legacy in October 2024 but keeps serving
|
|
30
|
+
* it to custom apps; the version is pinned so a Shopify-side change is a
|
|
31
|
+
* deliberate bump here, not a silent shape change.
|
|
32
|
+
*/
|
|
33
|
+
export declare const SHOPIFY_ADMIN_API_VERSION: '2026-10';
|
|
34
|
+
export declare const shopifyAccountAdapter: AccountAdapter;
|
|
35
|
+
export declare interface ShopifyAddressPayload {
|
|
36
|
+
first_name?: string | null
|
|
37
|
+
last_name?: string | null
|
|
38
|
+
name?: string | null
|
|
39
|
+
address1?: string | null
|
|
40
|
+
address2?: string | null
|
|
41
|
+
city?: string | null
|
|
42
|
+
province?: string | null
|
|
43
|
+
province_code?: string | null
|
|
44
|
+
zip?: string | null
|
|
45
|
+
country?: string | null
|
|
46
|
+
country_code?: string | null
|
|
47
|
+
phone?: string | null
|
|
48
|
+
}
|
|
49
|
+
export declare interface ShopifyCustomerPayload {
|
|
50
|
+
id: number | string
|
|
51
|
+
email?: string | null
|
|
52
|
+
first_name?: string | null
|
|
53
|
+
last_name?: string | null
|
|
54
|
+
phone?: string | null
|
|
55
|
+
state?: string | null
|
|
56
|
+
total_spent?: string | null
|
|
57
|
+
currency?: string | null
|
|
58
|
+
created_at?: string | null
|
|
59
|
+
default_address?: ShopifyAddressPayload | null
|
|
60
|
+
}
|
|
61
|
+
export declare interface ShopifyLineItemPayload {
|
|
62
|
+
id: number | string
|
|
63
|
+
product_id?: number | string | null
|
|
64
|
+
variant_id?: number | string | null
|
|
65
|
+
sku?: string | null
|
|
66
|
+
title?: string | null
|
|
67
|
+
variant_title?: string | null
|
|
68
|
+
name?: string | null
|
|
69
|
+
quantity?: number | null
|
|
70
|
+
price?: string | null
|
|
71
|
+
}
|
|
72
|
+
export declare interface ShopifyOrderPayload {
|
|
73
|
+
id: number | string
|
|
74
|
+
name?: string | null
|
|
75
|
+
order_number?: number | null
|
|
76
|
+
email?: string | null
|
|
77
|
+
contact_email?: string | null
|
|
78
|
+
created_at?: string | null
|
|
79
|
+
processed_at?: string | null
|
|
80
|
+
cancelled_at?: string | null
|
|
81
|
+
currency?: string | null
|
|
82
|
+
total_price?: string | null
|
|
83
|
+
total_tax?: string | null
|
|
84
|
+
total_discounts?: string | null
|
|
85
|
+
total_shipping_price_set?: { shop_money?: { amount?: string | null, currency_code?: string | null } | null } | null
|
|
86
|
+
shipping_lines?: Array<{ price?: string | null }> | null
|
|
87
|
+
total_tip_received?: string | null
|
|
88
|
+
financial_status?: string | null
|
|
89
|
+
fulfillment_status?: string | null
|
|
90
|
+
customer?: (Partial<ShopifyCustomerPayload> & { id?: number | string | null }) | null
|
|
91
|
+
billing_address?: ShopifyAddressPayload | null
|
|
92
|
+
shipping_address?: ShopifyAddressPayload | null
|
|
93
|
+
line_items?: ShopifyLineItemPayload[] | null
|
|
94
|
+
note?: string | null
|
|
95
|
+
}
|