@drawbridge/drawbridge-utils 0.0.175 → 0.0.177
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/admin-B9ZaLvan.d.cts +697 -0
- package/dist/admin-C3HtEM6h.d.ts +697 -0
- package/dist/billing-Bc4yo9XG.d.cts +175 -0
- package/dist/billing-mNsKflmQ.d.ts +175 -0
- package/dist/billing.d.cts +1 -1
- package/dist/billing.d.ts +1 -1
- package/dist/connections/index.cjs +5928 -1920
- package/dist/connections/index.d.cts +20 -9163
- package/dist/connections/index.d.ts +20 -9163
- package/dist/connections/index.js +5917 -1921
- package/dist/connections/oauth.cjs +4 -4
- package/dist/connections/oauth.d.cts +10 -8
- package/dist/connections/oauth.d.ts +10 -8
- package/dist/connections/oauth.js +4 -4
- package/dist/features.cjs +10260 -42
- package/dist/features.d.cts +55 -42
- package/dist/features.d.ts +55 -42
- package/dist/features.js +10254 -42
- package/dist/http.cjs +10 -1
- package/dist/http.d.cts +10 -1
- package/dist/http.d.ts +10 -1
- package/dist/http.js +10 -1
- package/dist/index-B546oDNo.d.ts +13809 -0
- package/dist/index-C59xHago.d.cts +13809 -0
- package/dist/oauth/index.d.cts +1 -1
- package/dist/oauth/index.d.ts +1 -1
- package/dist/oauth-BJDh0sdM.d.cts +527 -0
- package/dist/oauth-DveZMLHx.d.ts +527 -0
- package/dist/partner-BOZltuh2.d.ts +94 -0
- package/dist/partner-ed2OfW1J.d.cts +94 -0
- package/dist/plans.cjs +10256 -51
- package/dist/plans.d.cts +37 -10
- package/dist/plans.d.ts +37 -10
- package/dist/plans.js +10262 -51
- package/dist/pricing.cjs +10401 -209
- package/dist/pricing.d.cts +34 -17
- package/dist/pricing.d.ts +34 -17
- package/dist/pricing.js +10407 -209
- package/dist/providers.cjs +5433 -1755
- package/dist/providers.d.cts +13 -15
- package/dist/providers.d.ts +13 -15
- package/dist/providers.js +5436 -1752
- package/dist/sendgrid.cjs +10 -1
- package/dist/sendgrid.js +10 -1
- package/dist/shopify/admin.cjs +562 -0
- package/dist/shopify/admin.d.cts +3 -0
- package/dist/shopify/admin.d.ts +3 -0
- package/dist/shopify/admin.js +528 -0
- package/dist/shopify/billing.cjs +166 -0
- package/dist/shopify/billing.d.cts +3 -0
- package/dist/shopify/billing.d.ts +3 -0
- package/dist/shopify/billing.js +140 -0
- package/dist/shopify/constants.cjs +63 -0
- package/dist/shopify/constants.d.cts +58 -0
- package/dist/shopify/constants.d.ts +58 -0
- package/dist/shopify/constants.js +32 -0
- package/dist/shopify/oauth.cjs +509 -0
- package/dist/shopify/oauth.d.cts +7 -0
- package/dist/shopify/oauth.d.ts +7 -0
- package/dist/shopify/oauth.js +466 -0
- package/dist/shopify/partner.cjs +156 -0
- package/dist/shopify/partner.d.cts +3 -0
- package/dist/shopify/partner.d.ts +3 -0
- package/dist/shopify/partner.js +130 -0
- package/dist/shopify/storefront.cjs +611 -0
- package/dist/shopify/storefront.d.cts +3 -0
- package/dist/shopify/storefront.d.ts +3 -0
- package/dist/shopify/storefront.js +576 -0
- package/dist/storefront-C8FKOGeD.d.cts +659 -0
- package/dist/storefront-DJFGLqPl.d.ts +659 -0
- package/dist/twilio.cjs +10 -1
- package/dist/twilio.js +10 -1
- package/package.json +98 -68
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { request } from './http.cjs';
|
|
2
|
+
import { SHOPIFY_ADMIN_API_VERSION } from './shopify/constants.cjs';
|
|
3
|
+
|
|
4
|
+
const adminUrl = ( domain ) =>
|
|
5
|
+
`https://${ domain }/admin/api/${ SHOPIFY_ADMIN_API_VERSION }`;
|
|
6
|
+
|
|
7
|
+
// Admin GraphQL wrapper. Throws on top-level `errors`; user-input
|
|
8
|
+
// validation errors come back inside the operation's `userErrors` field
|
|
9
|
+
// and must be checked at the call site (see getOrCreateCustomer).
|
|
10
|
+
const adminFetch = async ({ adminAccessToken, domain, fetcher, query, variables }) => {
|
|
11
|
+
|
|
12
|
+
const { data, errors } = await request({
|
|
13
|
+
fetcher,
|
|
14
|
+
method : 'POST',
|
|
15
|
+
url : adminUrl( domain ) + '/graphql.json',
|
|
16
|
+
headers : {
|
|
17
|
+
'X-Shopify-Access-Token' : adminAccessToken
|
|
18
|
+
},
|
|
19
|
+
body : {
|
|
20
|
+
query,
|
|
21
|
+
...( variables && { variables } )
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
if( errors ){
|
|
26
|
+
|
|
27
|
+
throw new Error( 'Shopify GraphQL error: ' + errors[ 0 ]?.message );
|
|
28
|
+
|
|
29
|
+
}
|
|
30
|
+
return data;
|
|
31
|
+
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// WHAT MAKES A DISCOUNT UNUSABLE, fetched so the picker can say so instead of
|
|
35
|
+
// handing a merchant a code that silently never applies. All four code-discount
|
|
36
|
+
// types carry this same field set in 2026-04 (verified per type in
|
|
37
|
+
// shopify.dev/docs/api/admin-graphql/2026-04/objects/DiscountCode{Basic,Bxgy,
|
|
38
|
+
// FreeShipping,App}, fetched 2026-09-11), so one shape repeats rather than four
|
|
39
|
+
// different ones drifting apart.
|
|
40
|
+
//
|
|
41
|
+
// `context` OVER `customerSelection`: the latter is deprecated in 2026-04, the
|
|
42
|
+
// former is not. Only `__typename` is selected from it — it is a union, and
|
|
43
|
+
// naming its members in inline fragments would make this query fail the day
|
|
44
|
+
// Shopify adds one. `DiscountBuyerSelectionAll` is the only member that means
|
|
45
|
+
// "anyone can redeem this"; every other member is a restriction, whatever it is
|
|
46
|
+
// called, and that is the whole question being asked.
|
|
47
|
+
//
|
|
48
|
+
// `asyncUsageCount` is documented as possibly LAGGING the true count, so it is
|
|
49
|
+
// only ever compared as a lower bound: count >= limit means genuinely exhausted,
|
|
50
|
+
// never a false block.
|
|
51
|
+
const DISCOUNT_FIELDS = `
|
|
52
|
+
title
|
|
53
|
+
status
|
|
54
|
+
startsAt
|
|
55
|
+
endsAt
|
|
56
|
+
usageLimit
|
|
57
|
+
asyncUsageCount
|
|
58
|
+
appliesOncePerCustomer
|
|
59
|
+
context { __typename }
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
const discountsQuery = ( search, cursor, limit ) => `{
|
|
63
|
+
codeDiscountNodes(first: ${ limit }${ search ? `, query: "title:*${ search }*"` : '' }${ cursor ? `, after: "${ cursor }"` : '' }) {
|
|
64
|
+
edges {
|
|
65
|
+
node {
|
|
66
|
+
id
|
|
67
|
+
codeDiscount {
|
|
68
|
+
... on DiscountCodeBasic { ${ DISCOUNT_FIELDS } }
|
|
69
|
+
... on DiscountCodeBxgy { ${ DISCOUNT_FIELDS } }
|
|
70
|
+
... on DiscountCodeFreeShipping { ${ DISCOUNT_FIELDS } }
|
|
71
|
+
... on DiscountCodeApp { ${ DISCOUNT_FIELDS } }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
pageInfo {
|
|
76
|
+
endCursor
|
|
77
|
+
hasNextPage
|
|
78
|
+
hasPreviousPage
|
|
79
|
+
startCursor
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}`;
|
|
83
|
+
|
|
84
|
+
const getDiscounts = async ({ fetcher, adminAccessToken, domain, search, cursor, limit }) => {
|
|
85
|
+
|
|
86
|
+
const data = await adminFetch({
|
|
87
|
+
fetcher,
|
|
88
|
+
adminAccessToken,
|
|
89
|
+
domain,
|
|
90
|
+
query : discountsQuery( search, cursor, limit )
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return data?.codeDiscountNodes;
|
|
94
|
+
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Discount ids: the api's discounts endpoint strips the DiscountCodeNode
|
|
98
|
+
// GID to its bare numeric tail before it reaches the picker, so workflow
|
|
99
|
+
// step settings store numerics — but GraphQL wants the full GID
|
|
100
|
+
// (DRAWBRIDGE-SYNC-AQ). Accept either and normalize, same pattern as
|
|
101
|
+
// billing's toShopGid.
|
|
102
|
+
const discountGid = ( id ) =>
|
|
103
|
+
String( id ).startsWith( 'gid://' ) ? String( id ) : 'gid://shopify/DiscountCodeNode/' + id;
|
|
104
|
+
|
|
105
|
+
// Adds a code to an existing code discount. `discountId` is the
|
|
106
|
+
// DiscountCodeNode id in either GID or bare numeric form (see discountGid).
|
|
107
|
+
// discountRedeemCodeBulkAdd is asynchronous: it returns a bulk-creation
|
|
108
|
+
// job, not the created code, so we poll the job until done to read the
|
|
109
|
+
// redeem code's id back. The code string itself is caller-generated — the
|
|
110
|
+
// id is the only Shopify-issued value here. Requires the write_discounts
|
|
111
|
+
// scope.
|
|
112
|
+
const createDiscountCode = async ({ fetcher, adminAccessToken, domain, discountId, code }) => {
|
|
113
|
+
|
|
114
|
+
const added = await adminFetch({
|
|
115
|
+
fetcher,
|
|
116
|
+
adminAccessToken,
|
|
117
|
+
domain,
|
|
118
|
+
query : `
|
|
119
|
+
mutation DiscountRedeemCodeBulkAdd($discountId: ID!, $codes: [DiscountRedeemCodeInput!]!) {
|
|
120
|
+
discountRedeemCodeBulkAdd(discountId: $discountId, codes: $codes) {
|
|
121
|
+
bulkCreation {
|
|
122
|
+
id
|
|
123
|
+
}
|
|
124
|
+
userErrors {
|
|
125
|
+
field
|
|
126
|
+
message
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
`,
|
|
131
|
+
variables : {
|
|
132
|
+
codes : [ { code } ],
|
|
133
|
+
discountId : discountGid( discountId )
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const addErrors = added?.discountRedeemCodeBulkAdd?.userErrors;
|
|
138
|
+
|
|
139
|
+
if( addErrors?.length ){
|
|
140
|
+
|
|
141
|
+
throw new Error( 'Shopify discount code create failed: ' + addErrors[ 0 ].message );
|
|
142
|
+
|
|
143
|
+
}
|
|
144
|
+
const bulkCreationId = added?.discountRedeemCodeBulkAdd?.bulkCreation?.id;
|
|
145
|
+
|
|
146
|
+
if( ! bulkCreationId ){
|
|
147
|
+
|
|
148
|
+
throw new Error( 'Shopify discount code create failed: no bulk creation id returned' );
|
|
149
|
+
|
|
150
|
+
}
|
|
151
|
+
// Single-code jobs settle in a second or two; the cap keeps a stuck job
|
|
152
|
+
// from hanging the worker to its full budget — the step fails and the
|
|
153
|
+
// queue's retry semantics take over.
|
|
154
|
+
for( let attempt = 0; attempt < 15; attempt++ ){
|
|
155
|
+
|
|
156
|
+
await new Promise( ( resolve ) => setTimeout( resolve, 1000 ) );
|
|
157
|
+
|
|
158
|
+
const polled = await adminFetch({
|
|
159
|
+
fetcher,
|
|
160
|
+
adminAccessToken,
|
|
161
|
+
domain,
|
|
162
|
+
query : `
|
|
163
|
+
query DiscountRedeemCodeBulkCreationPoll($id: ID!) {
|
|
164
|
+
discountRedeemCodeBulkCreation(id: $id) {
|
|
165
|
+
done
|
|
166
|
+
codes(first: 1) {
|
|
167
|
+
nodes {
|
|
168
|
+
code
|
|
169
|
+
discountRedeemCode {
|
|
170
|
+
id
|
|
171
|
+
}
|
|
172
|
+
errors {
|
|
173
|
+
message
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
`,
|
|
180
|
+
variables : {
|
|
181
|
+
id : bulkCreationId
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const creation = polled?.discountRedeemCodeBulkCreation;
|
|
186
|
+
|
|
187
|
+
if( ! creation?.done ) continue;
|
|
188
|
+
|
|
189
|
+
const node = creation.codes?.nodes?.[ 0 ];
|
|
190
|
+
|
|
191
|
+
if( ! node?.discountRedeemCode?.id ){
|
|
192
|
+
|
|
193
|
+
throw new Error( 'Shopify discount code create failed: ' + ( node?.errors?.[ 0 ]?.message || 'code was not created' ) );
|
|
194
|
+
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
code : node.code,
|
|
198
|
+
// Numeric tail keeps the stored shopifyDiscountId in the shape the
|
|
199
|
+
// legacy REST endpoint returned — it feeds a merchant-visible
|
|
200
|
+
// workflow template variable, so no gid:// leakage.
|
|
201
|
+
id : node.discountRedeemCode.id.split( '/' ).pop()
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
}
|
|
205
|
+
throw new Error( 'Shopify discount code create timed out waiting for bulk creation ' + bulkCreationId );
|
|
206
|
+
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Per-connection webhook registration lived here — get / find / create / delete
|
|
210
|
+
// WebhookSubscription, plus the gid and topic-enum helpers they needed.
|
|
211
|
+
//
|
|
212
|
+
// It is gone because Shopify webhooks are DECLARATIVE now: the topics and their
|
|
213
|
+
// uri are declared in shopify.app.production.toml and `shopify app deploy`
|
|
214
|
+
// applies them to every install. Nothing called these.
|
|
215
|
+
//
|
|
216
|
+
// Do not bring them back to "fix" a webhook. findWebhookSubscription matched on
|
|
217
|
+
// topic AND uri, so a changed uri returned null and the caller created a SECOND
|
|
218
|
+
// subscription while the old one kept firing. Alongside Shopify-managed
|
|
219
|
+
// subscriptions that is a duplicate delivery, not a repair. Change the toml and
|
|
220
|
+
// deploy the app version instead.
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// Idempotent customer lookup-or-create. Shopify rejects duplicate emails
|
|
224
|
+
// on customerCreate, so we lookup first; on a race where two callers
|
|
225
|
+
// hit customerCreate simultaneously, the "Email has already been taken"
|
|
226
|
+
// userError triggers a second lookup to recover the now-existing record.
|
|
227
|
+
const getOrCreateCustomer = async ({ fetcher, adminAccessToken, domain, email, firstName, lastName, source }) => {
|
|
228
|
+
|
|
229
|
+
// Values are passed as GraphQL variables (not interpolated) so names with
|
|
230
|
+
// apostrophes (e.g. O'Brien) can't break the query or inject.
|
|
231
|
+
const lookupQuery = `
|
|
232
|
+
query customerByEmail( $query : String! ) {
|
|
233
|
+
customers( first : 1, query : $query ) {
|
|
234
|
+
edges {
|
|
235
|
+
node {
|
|
236
|
+
id
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
const data = await adminFetch({
|
|
244
|
+
fetcher,
|
|
245
|
+
adminAccessToken,
|
|
246
|
+
domain,
|
|
247
|
+
query : lookupQuery,
|
|
248
|
+
variables : { query : 'email:' + email }
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const existing = data?.customers?.edges?.[ 0 ]?.node;
|
|
252
|
+
|
|
253
|
+
if( existing ) return existing;
|
|
254
|
+
|
|
255
|
+
// Name + provenance metafield only apply here, on genuine create — an
|
|
256
|
+
// existing customer is returned untouched above.
|
|
257
|
+
const input = { email };
|
|
258
|
+
|
|
259
|
+
if( firstName ) input.firstName = firstName;
|
|
260
|
+
if( lastName ) input.lastName = lastName;
|
|
261
|
+
|
|
262
|
+
if( source ){
|
|
263
|
+
|
|
264
|
+
input.metafields = [{
|
|
265
|
+
namespace : 'drwbrdg',
|
|
266
|
+
key : 'source',
|
|
267
|
+
type : 'single_line_text_field',
|
|
268
|
+
value : source
|
|
269
|
+
}];
|
|
270
|
+
|
|
271
|
+
}
|
|
272
|
+
const result = await adminFetch({
|
|
273
|
+
fetcher,
|
|
274
|
+
adminAccessToken,
|
|
275
|
+
domain,
|
|
276
|
+
query : `
|
|
277
|
+
mutation customerCreate( $input : CustomerInput! ) {
|
|
278
|
+
customerCreate( input : $input ) {
|
|
279
|
+
customer {
|
|
280
|
+
id
|
|
281
|
+
}
|
|
282
|
+
userErrors {
|
|
283
|
+
field
|
|
284
|
+
message
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
`,
|
|
289
|
+
variables : { input }
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const userErrors = result?.customerCreate?.userErrors;
|
|
293
|
+
|
|
294
|
+
if( userErrors?.length ){
|
|
295
|
+
|
|
296
|
+
const emailTaken = userErrors.some( ( e ) => e.message?.includes( 'Email has already been taken' ) );
|
|
297
|
+
|
|
298
|
+
if( emailTaken ){
|
|
299
|
+
|
|
300
|
+
const retry = await adminFetch({
|
|
301
|
+
fetcher,
|
|
302
|
+
adminAccessToken,
|
|
303
|
+
domain,
|
|
304
|
+
query : lookupQuery,
|
|
305
|
+
variables : { query : 'email:' + email }
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const found = retry?.customers?.edges?.[ 0 ]?.node;
|
|
309
|
+
|
|
310
|
+
if( found ) return found;
|
|
311
|
+
|
|
312
|
+
}
|
|
313
|
+
throw new Error( 'Shopify customer create failed: ' + userErrors[ 0 ].message );
|
|
314
|
+
|
|
315
|
+
}
|
|
316
|
+
return result?.customerCreate?.customer;
|
|
317
|
+
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Returns the store's currently-active app subscriptions (App Pricing
|
|
321
|
+
// plans the merchant has approved) via `currentAppInstallation`. Shopify
|
|
322
|
+
// scopes `activeSubscriptions` to THIS app for the shop the admin token
|
|
323
|
+
// authenticates against — it only ever lists ACTIVE subscriptions, so a
|
|
324
|
+
// non-empty array means the merchant has an approved paid/usage plan for
|
|
325
|
+
// our app. Also returns `shopDomain` (the app installation's shop) so the
|
|
326
|
+
// caller can confirm the token resolved to the expected store. Used by the
|
|
327
|
+
// OAuth activate route to gate connection activation on real plan approval.
|
|
328
|
+
//
|
|
329
|
+
// Each subscription carries `metered`: whether the APPROVAL the merchant sits
|
|
330
|
+
// on includes a usage component. Per Shopify's AppSubscriptionLineItem
|
|
331
|
+
// contract, a subscription with both recurring and usage pricing has two line
|
|
332
|
+
// items — one AppRecurringPricing, one AppUsagePricing. A meter added to the
|
|
333
|
+
// plan config is NEVER retroactive: it bills only for merchants who approved
|
|
334
|
+
// the plan version that carries it, so an ACTIVE subscription whose line items
|
|
335
|
+
// show no AppUsagePricing is a store whose usage events are silently ingested
|
|
336
|
+
// as plain custom events — accepted with a 202 and never billed. `metered` is
|
|
337
|
+
// what lets a caller detect that state and ask the merchant to re-approve.
|
|
338
|
+
const getActiveAppSubscriptions = async ({ fetcher, adminAccessToken, domain }) => {
|
|
339
|
+
|
|
340
|
+
const data = await adminFetch({
|
|
341
|
+
fetcher,
|
|
342
|
+
adminAccessToken,
|
|
343
|
+
domain,
|
|
344
|
+
query : `{
|
|
345
|
+
currentAppInstallation {
|
|
346
|
+
activeSubscriptions {
|
|
347
|
+
id
|
|
348
|
+
name
|
|
349
|
+
status
|
|
350
|
+
test
|
|
351
|
+
lineItems {
|
|
352
|
+
id
|
|
353
|
+
plan {
|
|
354
|
+
pricingDetails {
|
|
355
|
+
__typename
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
shop {
|
|
362
|
+
myshopifyDomain
|
|
363
|
+
}
|
|
364
|
+
}`
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
shopDomain : data?.shop?.myshopifyDomain || null,
|
|
369
|
+
subscriptions : ( data?.currentAppInstallation?.activeSubscriptions || [] ).map( ( subscription ) => ({
|
|
370
|
+
...subscription,
|
|
371
|
+
metered : ( subscription?.lineItems || [] ).some(
|
|
372
|
+
( item ) => item?.plan?.pricingDetails?.__typename === 'AppUsagePricing'
|
|
373
|
+
),
|
|
374
|
+
// The usage line's own id — the address appUsageRecordCreate charges
|
|
375
|
+
// against. Null when the approval carries no usage component.
|
|
376
|
+
usageLineItemId : ( subscription?.lineItems || [] ).find(
|
|
377
|
+
( item ) => item?.plan?.pricingDetails?.__typename === 'AppUsagePricing'
|
|
378
|
+
)?.id || null
|
|
379
|
+
}) )
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Creates the app's own subscription for a store: a $0 recurring line plus a
|
|
385
|
+
// usage line, via the GA Billing API — NOT the managed-pricing meter system.
|
|
386
|
+
// Shopify returns a confirmationUrl; the merchant approves there (charge
|
|
387
|
+
// consent is always Shopify-hosted), and on approval the subscription goes
|
|
388
|
+
// ACTIVE carrying the usage line appUsageRecordCreate charges against.
|
|
389
|
+
//
|
|
390
|
+
// This is both the connect-time path and the heal path: because the pricing
|
|
391
|
+
// rides IN the subscription we create, a store can always be fixed by minting
|
|
392
|
+
// a fresh subscription and having the owner approve it — no dashboard-
|
|
393
|
+
// configured meter anywhere in the loop. Note Shopify refuses this mutation
|
|
394
|
+
// while the app has managed pricing (Shopify App Pricing) enabled: the
|
|
395
|
+
// userErrors say so verbatim, and the fix is the one-time pricing-model
|
|
396
|
+
// switch in the app's dashboard settings.
|
|
397
|
+
//
|
|
398
|
+
// `terms` is merchant-visible on the approval screen and REQUIRED for usage
|
|
399
|
+
// pricing. `cappedAmount` bounds a cycle's usage charges; a record that would
|
|
400
|
+
// exceed it errors synchronously, and only the merchant can raise it.
|
|
401
|
+
// `test : true` marks a test subscription nothing can ever charge.
|
|
402
|
+
const createUsageSubscription = async ({ fetcher, adminAccessToken, cappedAmount, currencyCode = 'USD', domain, name, recurringPrice = 0, returnUrl, terms, test = false }) => {
|
|
403
|
+
|
|
404
|
+
if( ! name || ! returnUrl || ! terms || ! ( Number( cappedAmount ) > 0 ) ){
|
|
405
|
+
|
|
406
|
+
throw new Error( 'createUsageSubscription requires name, returnUrl, terms, and a positive cappedAmount' );
|
|
407
|
+
|
|
408
|
+
}
|
|
409
|
+
const data = await adminFetch({
|
|
410
|
+
fetcher,
|
|
411
|
+
adminAccessToken,
|
|
412
|
+
domain,
|
|
413
|
+
query : `mutation createUsageSubscription( $lineItems : [AppSubscriptionLineItemInput!]!, $name : String!, $returnUrl : URL!, $test : Boolean ) {
|
|
414
|
+
appSubscriptionCreate( lineItems : $lineItems, name : $name, returnUrl : $returnUrl, test : $test ) {
|
|
415
|
+
appSubscription { id name status test lineItems { id plan { pricingDetails { __typename } } } }
|
|
416
|
+
confirmationUrl
|
|
417
|
+
userErrors { field message }
|
|
418
|
+
}
|
|
419
|
+
}`,
|
|
420
|
+
variables : {
|
|
421
|
+
lineItems : [
|
|
422
|
+
{
|
|
423
|
+
plan : {
|
|
424
|
+
appRecurringPricingDetails : {
|
|
425
|
+
interval : 'EVERY_30_DAYS',
|
|
426
|
+
price : { amount : Number( recurringPrice ), currencyCode }
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
plan : {
|
|
432
|
+
appUsagePricingDetails : {
|
|
433
|
+
cappedAmount : { amount : Number( cappedAmount ), currencyCode },
|
|
434
|
+
terms
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
],
|
|
439
|
+
name,
|
|
440
|
+
returnUrl,
|
|
441
|
+
test
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const payload = data?.appSubscriptionCreate;
|
|
446
|
+
const userErrors = payload?.userErrors || [];
|
|
447
|
+
|
|
448
|
+
if( userErrors.length ){
|
|
449
|
+
|
|
450
|
+
throw new Error( 'Shopify subscription create failed: ' + userErrors.map( ( entry ) => entry.message ).join( '; ' ) );
|
|
451
|
+
|
|
452
|
+
}
|
|
453
|
+
if( ! payload?.confirmationUrl ){
|
|
454
|
+
|
|
455
|
+
throw new Error( 'Shopify subscription create failed: no confirmation url returned' );
|
|
456
|
+
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
confirmationUrl : payload.confirmationUrl,
|
|
460
|
+
subscription : payload.appSubscription
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// Charges one usage amount against a subscription's usage line — the actual
|
|
466
|
+
// billing call under the Billing API model, replacing the App Events send.
|
|
467
|
+
// Unlike that 202-and-pray API, this answers synchronously: a created record
|
|
468
|
+
// id is a receipt, a cap overrun or dead line item is an error the caller can
|
|
469
|
+
// retry or surface. `idempotencyKey` (e.g. the order id) makes a retried
|
|
470
|
+
// charge safe.
|
|
471
|
+
const createUsageRecord = async ({ fetcher, adminAccessToken, amount, currencyCode = 'USD', description, domain, idempotencyKey, subscriptionLineItemId }) => {
|
|
472
|
+
|
|
473
|
+
if( ! subscriptionLineItemId || ! description || ! ( Number( amount ) > 0 ) ){
|
|
474
|
+
|
|
475
|
+
throw new Error( 'createUsageRecord requires subscriptionLineItemId, description, and a positive amount' );
|
|
476
|
+
|
|
477
|
+
}
|
|
478
|
+
const data = await adminFetch({
|
|
479
|
+
fetcher,
|
|
480
|
+
adminAccessToken,
|
|
481
|
+
domain,
|
|
482
|
+
query : `mutation createUsageRecord( $description : String!, $idempotencyKey : String, $price : MoneyInput!, $subscriptionLineItemId : ID! ) {
|
|
483
|
+
appUsageRecordCreate( description : $description, idempotencyKey : $idempotencyKey, price : $price, subscriptionLineItemId : $subscriptionLineItemId ) {
|
|
484
|
+
appUsageRecord { id }
|
|
485
|
+
userErrors { field message }
|
|
486
|
+
}
|
|
487
|
+
}`,
|
|
488
|
+
variables : {
|
|
489
|
+
description,
|
|
490
|
+
idempotencyKey : idempotencyKey ? String( idempotencyKey ) : null,
|
|
491
|
+
price : { amount : Number( amount ), currencyCode },
|
|
492
|
+
subscriptionLineItemId
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const payload = data?.appUsageRecordCreate;
|
|
497
|
+
const userErrors = payload?.userErrors || [];
|
|
498
|
+
|
|
499
|
+
if( userErrors.length || ! payload?.appUsageRecord?.id ){
|
|
500
|
+
|
|
501
|
+
throw new Error( 'Shopify usage record failed: ' + ( userErrors.map( ( entry ) => entry.message ).join( '; ' ) || 'no record returned' ) );
|
|
502
|
+
|
|
503
|
+
}
|
|
504
|
+
return { id : payload.appUsageRecord.id };
|
|
505
|
+
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
// Returns a { id: quantity } map for every variant on the product
|
|
509
|
+
// (keyed by variant external id). Inventory comes from Admin API
|
|
510
|
+
// (Storefront doesn't expose inventory counts) so this requires the
|
|
511
|
+
// admin token, not storefront.
|
|
512
|
+
const getProductInventory = async ({ fetcher, adminAccessToken, domain, productId }) => {
|
|
513
|
+
|
|
514
|
+
const data = await adminFetch({
|
|
515
|
+
fetcher,
|
|
516
|
+
adminAccessToken,
|
|
517
|
+
domain,
|
|
518
|
+
query : `{
|
|
519
|
+
product( id : "${ productId }" ) {
|
|
520
|
+
variants( first : 100 ) {
|
|
521
|
+
edges {
|
|
522
|
+
node {
|
|
523
|
+
id
|
|
524
|
+
inventoryQuantity
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}`
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const edges = data?.product?.variants?.edges || [];
|
|
533
|
+
|
|
534
|
+
return edges.reduce( ( accumulator, edge ) => {
|
|
535
|
+
|
|
536
|
+
const id = edge?.node?.id;
|
|
537
|
+
const quantity = edge?.node?.inventoryQuantity;
|
|
538
|
+
|
|
539
|
+
if( id ){
|
|
540
|
+
|
|
541
|
+
accumulator[ id ] = Number.isFinite( quantity ) ? quantity : 0;
|
|
542
|
+
|
|
543
|
+
}
|
|
544
|
+
return accumulator;
|
|
545
|
+
|
|
546
|
+
}, {} );
|
|
547
|
+
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// Shopify versions product feedback on the product's OWN updatedAt and rejects
|
|
551
|
+
// a payload that looks stale ('Feedback for a later version of this resource was
|
|
552
|
+
// already accepted'), so the timestamp has to be forwarded, never invented — a
|
|
553
|
+
// `new Date()` here reads as a version far ahead of the product, and every later
|
|
554
|
+
// send is then refused as older than the version Shopify recorded.
|
|
555
|
+
//
|
|
556
|
+
// Deliberately only `updatedAt`: it needs read_products, which every install
|
|
557
|
+
// already grants. Reading publication state here instead would need either
|
|
558
|
+
// read_product_listings (publishedOnCurrentPublication — also deprecated) or the
|
|
559
|
+
// channel/publication join, and neither is worth a re-consent prompt to learn
|
|
560
|
+
// something the mutation below already reports.
|
|
561
|
+
const getProductUpdatedAt = async ({ adminAccessToken, domain, fetcher, productId }) => {
|
|
562
|
+
|
|
563
|
+
const data = await adminFetch({
|
|
564
|
+
fetcher,
|
|
565
|
+
adminAccessToken,
|
|
566
|
+
domain,
|
|
567
|
+
query : `
|
|
568
|
+
query ProductUpdatedAt($id: ID!) {
|
|
569
|
+
product(id: $id) {
|
|
570
|
+
updatedAt
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
`,
|
|
574
|
+
variables : { id : productId }
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return data?.product?.updatedAt || null;
|
|
578
|
+
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// Shopify carries product feedback only for a product published to the calling
|
|
582
|
+
// app's channel; for anything else it refuses with this. Matched rather than
|
|
583
|
+
// thrown because it is an expected state, not a fault — see the caller note.
|
|
584
|
+
const NOT_ON_CHANNEL = 'not available to the channel';
|
|
585
|
+
|
|
586
|
+
// Shopify versions feedback on the product's updatedAt and refuses a version
|
|
587
|
+
// older than the one it already holds. Before 0.0.30 the SEND TIME went out as
|
|
588
|
+
// that version, so every product accepted then sits on a version from the
|
|
589
|
+
// future, and its real updatedAt is refused until the merchant next edits it —
|
|
590
|
+
// while the stale banner stays up. Matched so the send can be repeated once at
|
|
591
|
+
// the send time, which Shopify takes.
|
|
592
|
+
const OUTDATED_FEEDBACK = 'later version';
|
|
593
|
+
|
|
594
|
+
// Product ResourceFeedback — surfaces required-action messages on the product
|
|
595
|
+
// page in Shopify admin (sales-channel requirement: communicate product issues
|
|
596
|
+
// through the ResourceFeedback API). `state` is 'REQUIRES_ACTION' or
|
|
597
|
+
// 'ACCEPTED'; ACCEPTED clears the product's active feedback. Sending replaces
|
|
598
|
+
// whatever feedback this app previously sent for the product. Requires the
|
|
599
|
+
// write_resource_feedbacks scope.
|
|
600
|
+
//
|
|
601
|
+
// Returns null when there is nothing to attach feedback to. That is a normal
|
|
602
|
+
// outcome, not a failure, and callers must not report it as one.
|
|
603
|
+
const sendProductResourceFeedback = async ({ fetcher, adminAccessToken, domain, productId, state, messages = [] }) => {
|
|
604
|
+
|
|
605
|
+
const productUpdatedAt = await getProductUpdatedAt({ adminAccessToken, domain, fetcher, productId });
|
|
606
|
+
|
|
607
|
+
// Deleted between the sync job reading it and this call.
|
|
608
|
+
if( ! productUpdatedAt ) return null;
|
|
609
|
+
|
|
610
|
+
const now = new Date().toISOString();
|
|
611
|
+
|
|
612
|
+
const submit = async ( version ) => {
|
|
613
|
+
|
|
614
|
+
const data = await adminFetch({
|
|
615
|
+
fetcher,
|
|
616
|
+
adminAccessToken,
|
|
617
|
+
domain,
|
|
618
|
+
query : `
|
|
619
|
+
mutation ProductFeedback($feedbackInput: [ProductResourceFeedbackInput!]!) {
|
|
620
|
+
bulkProductResourceFeedbackCreate(feedbackInput: $feedbackInput) {
|
|
621
|
+
feedback {
|
|
622
|
+
productId
|
|
623
|
+
state
|
|
624
|
+
}
|
|
625
|
+
userErrors {
|
|
626
|
+
field
|
|
627
|
+
message
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
`,
|
|
632
|
+
variables : {
|
|
633
|
+
feedbackInput : [
|
|
634
|
+
{
|
|
635
|
+
productId,
|
|
636
|
+
state,
|
|
637
|
+
feedbackGeneratedAt : now,
|
|
638
|
+
productUpdatedAt : version,
|
|
639
|
+
messages
|
|
640
|
+
}
|
|
641
|
+
]
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
return data?.bulkProductResourceFeedbackCreate;
|
|
646
|
+
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
let result = await submit( productUpdatedAt );
|
|
650
|
+
|
|
651
|
+
// A product poisoned by the pre-0.0.30 version stamp: resend once at the
|
|
652
|
+
// send time so the banner clears now rather than at the merchant's next
|
|
653
|
+
// edit. The resend's answer is final either way.
|
|
654
|
+
if( ( result?.userErrors?.[ 0 ]?.message || '' ).toLowerCase().includes( OUTDATED_FEEDBACK ) ){
|
|
655
|
+
|
|
656
|
+
result = await submit( now );
|
|
657
|
+
|
|
658
|
+
}
|
|
659
|
+
const feedbackErrors = result?.userErrors;
|
|
660
|
+
|
|
661
|
+
if( feedbackErrors?.length ){
|
|
662
|
+
|
|
663
|
+
const message = feedbackErrors[ 0 ].message || '';
|
|
664
|
+
|
|
665
|
+
// The 2026-08-12 QA finding: a product unpublished from the channel (or
|
|
666
|
+
// drafted, which also removes it) is exactly when REQUIRES_ACTION is worth
|
|
667
|
+
// saying and exactly when Shopify won't carry it — there is no Drawbridge
|
|
668
|
+
// section on that product page to render it in. Every send failed this way
|
|
669
|
+
// since the channel shipped. It is a state, not a fault: the embedded app's
|
|
670
|
+
// publishing section lists these products instead (App Store 5.7.8/5.7.11).
|
|
671
|
+
//
|
|
672
|
+
// Matching Shopify's wording is the trade for not spending a re-consent on
|
|
673
|
+
// read_product_listings just to pre-check. If they reword it this starts
|
|
674
|
+
// throwing, which now reaches Sentry Issues — loud, not silent.
|
|
675
|
+
if( message.toLowerCase().includes( NOT_ON_CHANNEL ) ) return null;
|
|
676
|
+
|
|
677
|
+
throw new Error( 'Shopify product feedback failed: ' + message );
|
|
678
|
+
|
|
679
|
+
}
|
|
680
|
+
return result?.feedback?.[ 0 ] || null;
|
|
681
|
+
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
declare const shopifyAdmin_adminFetch: typeof adminFetch;
|
|
685
|
+
declare const shopifyAdmin_createDiscountCode: typeof createDiscountCode;
|
|
686
|
+
declare const shopifyAdmin_createUsageRecord: typeof createUsageRecord;
|
|
687
|
+
declare const shopifyAdmin_createUsageSubscription: typeof createUsageSubscription;
|
|
688
|
+
declare const shopifyAdmin_getActiveAppSubscriptions: typeof getActiveAppSubscriptions;
|
|
689
|
+
declare const shopifyAdmin_getDiscounts: typeof getDiscounts;
|
|
690
|
+
declare const shopifyAdmin_getOrCreateCustomer: typeof getOrCreateCustomer;
|
|
691
|
+
declare const shopifyAdmin_getProductInventory: typeof getProductInventory;
|
|
692
|
+
declare const shopifyAdmin_sendProductResourceFeedback: typeof sendProductResourceFeedback;
|
|
693
|
+
declare namespace shopifyAdmin {
|
|
694
|
+
export { shopifyAdmin_adminFetch as adminFetch, shopifyAdmin_createDiscountCode as createDiscountCode, shopifyAdmin_createUsageRecord as createUsageRecord, shopifyAdmin_createUsageSubscription as createUsageSubscription, shopifyAdmin_getActiveAppSubscriptions as getActiveAppSubscriptions, shopifyAdmin_getDiscounts as getDiscounts, shopifyAdmin_getOrCreateCustomer as getOrCreateCustomer, shopifyAdmin_getProductInventory as getProductInventory, shopifyAdmin_sendProductResourceFeedback as sendProductResourceFeedback };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export { adminFetch as a, createUsageRecord as b, createDiscountCode as c, createUsageSubscription as d, getDiscounts as e, getOrCreateCustomer as f, getActiveAppSubscriptions as g, getProductInventory as h, shopifyAdmin as i, sendProductResourceFeedback as s };
|