@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.
Files changed (73) hide show
  1. package/dist/admin-B9ZaLvan.d.cts +697 -0
  2. package/dist/admin-C3HtEM6h.d.ts +697 -0
  3. package/dist/billing-Bc4yo9XG.d.cts +175 -0
  4. package/dist/billing-mNsKflmQ.d.ts +175 -0
  5. package/dist/billing.d.cts +1 -1
  6. package/dist/billing.d.ts +1 -1
  7. package/dist/connections/index.cjs +5928 -1920
  8. package/dist/connections/index.d.cts +20 -9163
  9. package/dist/connections/index.d.ts +20 -9163
  10. package/dist/connections/index.js +5917 -1921
  11. package/dist/connections/oauth.cjs +4 -4
  12. package/dist/connections/oauth.d.cts +10 -8
  13. package/dist/connections/oauth.d.ts +10 -8
  14. package/dist/connections/oauth.js +4 -4
  15. package/dist/features.cjs +10260 -42
  16. package/dist/features.d.cts +55 -42
  17. package/dist/features.d.ts +55 -42
  18. package/dist/features.js +10254 -42
  19. package/dist/http.cjs +10 -1
  20. package/dist/http.d.cts +10 -1
  21. package/dist/http.d.ts +10 -1
  22. package/dist/http.js +10 -1
  23. package/dist/index-B546oDNo.d.ts +13809 -0
  24. package/dist/index-C59xHago.d.cts +13809 -0
  25. package/dist/oauth/index.d.cts +1 -1
  26. package/dist/oauth/index.d.ts +1 -1
  27. package/dist/oauth-BJDh0sdM.d.cts +527 -0
  28. package/dist/oauth-DveZMLHx.d.ts +527 -0
  29. package/dist/partner-BOZltuh2.d.ts +94 -0
  30. package/dist/partner-ed2OfW1J.d.cts +94 -0
  31. package/dist/plans.cjs +10256 -51
  32. package/dist/plans.d.cts +37 -10
  33. package/dist/plans.d.ts +37 -10
  34. package/dist/plans.js +10262 -51
  35. package/dist/pricing.cjs +10401 -209
  36. package/dist/pricing.d.cts +34 -17
  37. package/dist/pricing.d.ts +34 -17
  38. package/dist/pricing.js +10407 -209
  39. package/dist/providers.cjs +5433 -1755
  40. package/dist/providers.d.cts +13 -15
  41. package/dist/providers.d.ts +13 -15
  42. package/dist/providers.js +5436 -1752
  43. package/dist/sendgrid.cjs +10 -1
  44. package/dist/sendgrid.js +10 -1
  45. package/dist/shopify/admin.cjs +562 -0
  46. package/dist/shopify/admin.d.cts +3 -0
  47. package/dist/shopify/admin.d.ts +3 -0
  48. package/dist/shopify/admin.js +528 -0
  49. package/dist/shopify/billing.cjs +166 -0
  50. package/dist/shopify/billing.d.cts +3 -0
  51. package/dist/shopify/billing.d.ts +3 -0
  52. package/dist/shopify/billing.js +140 -0
  53. package/dist/shopify/constants.cjs +63 -0
  54. package/dist/shopify/constants.d.cts +58 -0
  55. package/dist/shopify/constants.d.ts +58 -0
  56. package/dist/shopify/constants.js +32 -0
  57. package/dist/shopify/oauth.cjs +509 -0
  58. package/dist/shopify/oauth.d.cts +7 -0
  59. package/dist/shopify/oauth.d.ts +7 -0
  60. package/dist/shopify/oauth.js +466 -0
  61. package/dist/shopify/partner.cjs +156 -0
  62. package/dist/shopify/partner.d.cts +3 -0
  63. package/dist/shopify/partner.d.ts +3 -0
  64. package/dist/shopify/partner.js +130 -0
  65. package/dist/shopify/storefront.cjs +611 -0
  66. package/dist/shopify/storefront.d.cts +3 -0
  67. package/dist/shopify/storefront.d.ts +3 -0
  68. package/dist/shopify/storefront.js +576 -0
  69. package/dist/storefront-C8FKOGeD.d.cts +659 -0
  70. package/dist/storefront-DJFGLqPl.d.ts +659 -0
  71. package/dist/twilio.cjs +10 -1
  72. package/dist/twilio.js +10 -1
  73. package/package.json +98 -68
@@ -0,0 +1,697 @@
1
+ import { request } from './http.js';
2
+ import { SHOPIFY_ADMIN_API_VERSION } from './shopify/constants.js';
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 };