@drawbridge/drawbridge-utils 0.0.176 → 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 (69) 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 +3077 -228
  8. package/dist/connections/index.d.cts +13 -4
  9. package/dist/connections/index.d.ts +13 -4
  10. package/dist/connections/index.js +3107 -255
  11. package/dist/features.cjs +2940 -226
  12. package/dist/features.d.cts +13 -4
  13. package/dist/features.d.ts +13 -4
  14. package/dist/features.js +2980 -260
  15. package/dist/http.cjs +10 -1
  16. package/dist/http.d.cts +10 -1
  17. package/dist/http.d.ts +10 -1
  18. package/dist/http.js +10 -1
  19. package/dist/{index-B8JhYfvU.d.ts → index-B546oDNo.d.ts} +2837 -956
  20. package/dist/{index-qY18QITf.d.cts → index-C59xHago.d.cts} +2837 -956
  21. package/dist/oauth/index.d.cts +1 -1
  22. package/dist/oauth/index.d.ts +1 -1
  23. package/dist/oauth-BJDh0sdM.d.cts +527 -0
  24. package/dist/oauth-DveZMLHx.d.ts +527 -0
  25. package/dist/partner-BOZltuh2.d.ts +94 -0
  26. package/dist/partner-ed2OfW1J.d.cts +94 -0
  27. package/dist/plans.cjs +2939 -225
  28. package/dist/plans.d.cts +13 -4
  29. package/dist/plans.d.ts +13 -4
  30. package/dist/plans.js +2980 -260
  31. package/dist/pricing.cjs +2972 -258
  32. package/dist/pricing.d.cts +13 -4
  33. package/dist/pricing.d.ts +13 -4
  34. package/dist/pricing.js +2977 -257
  35. package/dist/providers.cjs +2942 -247
  36. package/dist/providers.d.cts +12 -3
  37. package/dist/providers.d.ts +12 -3
  38. package/dist/providers.js +2946 -245
  39. package/dist/sendgrid.cjs +10 -1
  40. package/dist/sendgrid.js +10 -1
  41. package/dist/shopify/admin.cjs +562 -0
  42. package/dist/shopify/admin.d.cts +3 -0
  43. package/dist/shopify/admin.d.ts +3 -0
  44. package/dist/shopify/admin.js +528 -0
  45. package/dist/shopify/billing.cjs +166 -0
  46. package/dist/shopify/billing.d.cts +3 -0
  47. package/dist/shopify/billing.d.ts +3 -0
  48. package/dist/shopify/billing.js +140 -0
  49. package/dist/shopify/constants.cjs +63 -0
  50. package/dist/shopify/constants.d.cts +58 -0
  51. package/dist/shopify/constants.d.ts +58 -0
  52. package/dist/shopify/constants.js +32 -0
  53. package/dist/shopify/oauth.cjs +509 -0
  54. package/dist/shopify/oauth.d.cts +7 -0
  55. package/dist/shopify/oauth.d.ts +7 -0
  56. package/dist/shopify/oauth.js +466 -0
  57. package/dist/shopify/partner.cjs +156 -0
  58. package/dist/shopify/partner.d.cts +3 -0
  59. package/dist/shopify/partner.d.ts +3 -0
  60. package/dist/shopify/partner.js +130 -0
  61. package/dist/shopify/storefront.cjs +611 -0
  62. package/dist/shopify/storefront.d.cts +3 -0
  63. package/dist/shopify/storefront.d.ts +3 -0
  64. package/dist/shopify/storefront.js +576 -0
  65. package/dist/storefront-C8FKOGeD.d.cts +659 -0
  66. package/dist/storefront-DJFGLqPl.d.ts +659 -0
  67. package/dist/twilio.cjs +10 -1
  68. package/dist/twilio.js +10 -1
  69. package/package.json +98 -68
@@ -1,11 +1,18 @@
1
1
  import { isDuplicateKey } from './usage.cjs';
2
+ import { decrypt, encrypt } from './encrypt.cjs';
2
3
  import { authToken } from './connections/oauth.cjs';
3
4
  import { toE164, detectCountry } from './phone.cjs';
4
5
  import crypto, { randomUUID, createHmac, timingSafeEqual, createVerify, createHash } from 'node:crypto';
5
6
  import { request } from './http.cjs';
6
7
  import { customAlphabet } from 'nanoid';
8
+ import { i as shopifyAdmin } from './admin-B9ZaLvan.cjs';
9
+ import { a as shopifyBilling } from './billing-Bc4yo9XG.cjs';
10
+ import { s as shopifyOauth } from './oauth-BJDh0sdM.cjs';
11
+ import { s as shopifyPartner } from './partner-ed2OfW1J.cjs';
12
+ import { s as shopifyStorefront } from './storefront-C8FKOGeD.cjs';
7
13
  import { toCanonicalEmail } from './email.cjs';
8
- import { decrypt } from './encrypt.cjs';
14
+ import { tokenTypes, hashToken } from './oauth/index.cjs';
15
+ import { sanitizeDomain } from './sanitize.cjs';
9
16
  import { slugify } from './slugify.cjs';
10
17
  import { safeRequest } from './safe-http.cjs';
11
18
 
@@ -158,6 +165,10 @@ const HOOKS = Object.freeze({
158
165
  'code',
159
166
  // Create the buyer at the vendor, so an order can be attributed to them.
160
167
  'customer',
168
+ // HOW MANY OF THIS PRODUCT ARE LEFT, at the vendor. Read at the moment a
169
+ // campaign's product list is rendered rather than stored, because stock is
170
+ // the one product fact that is stale the instant it is written down.
171
+ 'inventory',
161
172
  // An order arrived at the vendor: attribute it, record it, update totals.
162
173
  'order',
163
174
  // Pull product data across on a vendor update.
@@ -233,7 +244,19 @@ const HOOKS = Object.freeze({
233
244
 
234
245
  // What the merchant pays the VENDOR. Pairs with auth.install.billing, which
235
246
  // declares the plan they are being charged on.
236
- plan : Object.freeze([ 'cancel', 'reselect' ]),
247
+ //
248
+ // `verify` is the odd one of the three: not something a merchant does, but
249
+ // the question "is this store's billing actually metered right now". It is
250
+ // here because it is the same subject — and because the answer was being
251
+ // derived in TWO places, the vendor's health check and drawbridge-sync's
252
+ // approval webhook, whose copy said in its own comment that it matched the
253
+ // other and did not.
254
+ // `charge` and `verify` are the two that are not merchant actions: one
255
+ // sends a usage charge to the vendor's meter, the other asks whether that
256
+ // meter is actually working. Both are here because both are the same
257
+ // subject — what the merchant pays the vendor — and both were being done
258
+ // from drawbridge-sync with a direct import of the vendor's client.
259
+ plan : Object.freeze([ 'cancel', 'charge', 'reselect', 'verify' ]),
237
260
 
238
261
  // The embedded app's own session, stored by us on the vendor's behalf.
239
262
  session : Object.freeze([ 'clear', 'read', 'save' ]),
@@ -535,12 +558,22 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
535
558
  // -> features)
536
559
  // chunkSize the worker fleet's fan-out width — deployment tuning,
537
560
  // passed, never published
561
+ // currencies the settlement currencies Drawbridge can bill, so a
562
+ // store outside them is refused with the reason rather
563
+ // than failing a validator inside a transaction
538
564
  // dispatch() the caller's own coordinator table, for hooks that are
539
565
  // dispatches
566
+ // mark() the one write a description cannot carry because of its
567
+ // ORDER — install.plan.reselect must persist the marker
568
+ // BEFORE it releases the approval, and a described write
569
+ // lands after the hook returns
540
570
  // fetcher fetch-shaped transport: ( url, options )
541
571
  // logger the caller's logger
542
572
  // mintId() an id minted before the write it belongs to, so one
543
573
  // described write can reference another
574
+ // planTitle() the name of the Drawbridge plan an organization is on,
575
+ // given its subscription. Injected for the same reason as
576
+ // conversionRate, and from the same catalogue
544
577
  // read the controller's READ methods — get, aggregate, count —
545
578
  // and never anything that writes; not `controller`, not
546
579
  // `db`, because a name that implies writing invites it
@@ -553,10 +586,15 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
553
586
  // decides billing
554
587
  // resolveSettings() fresh from wherever the vendor keeps credentials, per
555
588
  // call, never a snapshot
589
+ // shopify the vendor's client namespaces. DEFAULTED to the real
590
+ // client, which the manifest imports directly — production
591
+ // injects nothing. Overridable because it is the SDK
592
+ // boundary, and that is where a hook test belongs: the
593
+ // health tests stub getActiveAppSubscriptions and assert
594
+ // the arguments it was called with
556
595
  // rotateToken() deliberate rotation, for the health check that rotates
557
596
  // before the window closes
558
- // shopify the SDK namespaces, injected — this package cannot
559
- // import what depends on it
597
+
560
598
  //
561
599
  // WHICH OF THESE A GIVEN SLOT ACTUALLY CARRIES is the table below, not this
562
600
  // glossary. A name here means one thing everywhere it appears; the table says
@@ -564,7 +602,13 @@ const WRITE_OPERATIONS = Object.freeze([ 'create', 'delete', 'update' ]);
564
602
  // Three signatures shared by a whole family, declared once so the family cannot
565
603
  // drift apart the way resources.* was starting to. Named rather than repeated:
566
604
  // a copy-pasted array is a copy that can be edited alone.
567
- const INSTALL_SIGNATURE = Object.freeze({ options : [ 'read', 'request' ], props : [ 'context' ] });
605
+ // THE INSTALL BASELINE: the request's own body, and the ability to read. Seven
606
+ // of the thirteen slots need nothing else — they answer from the shared install
607
+ // record and describe their writes. `request` came off it when the slots were
608
+ // implemented and none of them made an outbound call of its own; four reach the
609
+ // VENDOR, and they do it through `shopify`, which is the seam every other
610
+ // vendor-calling slot already uses.
611
+ const INSTALL_SIGNATURE = Object.freeze({ options : [ 'read' ], props : [ 'context' ] });
568
612
 
569
613
  const LIFECYCLE_SIGNATURE = Object.freeze({ options : [ 'dispatch' ], props : [ 'context' ] });
570
614
 
@@ -617,15 +661,30 @@ const HOOK_SLOT_PROPS = Object.freeze({
617
661
  // stubbed-fetcher-reaches-production failure the split exists to prevent.
618
662
  'auth.token' : false,
619
663
 
620
- // COMMERCE. `cart` is declared and unimplemented the Checkout Kit path in
621
- // api/route/subdomain.js writes nothing, which is why it never became a hook
622
- // so its signature mirrors `code`, its nearest sibling, and moving the
623
- // route here is a deliberate widening of this line rather than a silent one.
624
- 'commerce.cart' : Object.freeze({ options : [ 'adminToken', 'shopify' ], props : [ 'connection', 'context', 'step' ] }),
664
+ // COMMERCE. `cart` is the odd one: it runs for a SHOPPER on a public page,
665
+ // not for an organization inside a workflow, so it takes no `connection` and
666
+ // no `step`. It reads the storefront with the store's own storefront token
667
+ // and mints a checkout token from DRAWBRIDGE'S app credentials, which is why
668
+ // `clientId`/`clientSecret` are here as props, the way auth.disconnect
669
+ // already carries them, because a credential is a fact of the run rather
670
+ // than a service.
671
+ 'commerce.cart' : Object.freeze({
672
+ options : [ 'fetcher', 'shopify' ],
673
+ props : [ 'clientId', 'clientSecret', 'context', 'settings' ]
674
+ }),
625
675
  'commerce.code' : Object.freeze({ options : [ 'adminToken', 'shopify' ], props : [ 'connection', 'context', 'step' ] }),
626
676
  'commerce.customer' : Object.freeze({ options : [ 'adminToken', 'read', 'shopify' ], props : [ 'connection', 'context' ] }),
677
+ 'commerce.inventory' : Object.freeze({ options : [ 'adminToken', 'shopify' ], props : [ 'connection', 'context' ] }),
627
678
  'commerce.order' : Object.freeze({ options : [ 'conversionRate', 'logger', 'mintId', 'read' ], props : [ 'connection', 'context' ] }),
628
- 'commerce.product' : Object.freeze({ options : [ 'mintId', 'read' ], props : [ 'connection', 'context', 'workflow' ] }),
679
+ // THREE OPERATIONS, one slot record, sync, variants. `settings` and
680
+ // `adminToken` arrived with the pull half: the storefront read needs the
681
+ // store's storefront token and the product-feedback send needs its admin one,
682
+ // and both are resolved by the caller because minting either needs a
683
+ // controller.
684
+ 'commerce.product' : Object.freeze({
685
+ options : [ 'adminToken', 'mintId', 'read', 'shopify' ],
686
+ props : [ 'connection', 'context', 'settings', 'workflow' ]
687
+ }),
629
688
 
630
689
  // THE ADDRESS IS `contact` IN BOTH, and the shell already says why: it is the
631
690
  // person, not the lead document. `remove` called it `email` and `sync` called
@@ -641,19 +700,58 @@ const HOOK_SLOT_PROPS = Object.freeze({
641
700
  'inbound.receive' : Object.freeze({ options : [], props : [ 'channel', 'event', 'headers', 'payload' ] }),
642
701
  'inbound.verify' : Object.freeze({ options : [], props : [ 'body', 'channel', 'headers', 'secret', 'url' ] }),
643
702
 
644
- // INSTALL — thirteen slots, none implemented yet. They are what the thirteen
645
- // routes in api/route/shopify-embedded.js become, and the signature is
646
- // deliberately narrow: `context` is the request's own body, `read` and
647
- // `request` are the only services an install surface has been shown to need.
648
- // A route that cannot be expressed inside this line is a finding to bring
649
- // back here, not a name to add at the call site.
703
+ // INSTALL — thirteen slots, and they are the thirteen routes that used to be
704
+ // api/route/shopify-embedded.js. `context` is the request's own body in all
705
+ // of them; the options are where they differ, and every difference below was
706
+ // forced by a line in those routes rather than chosen.
707
+ //
708
+ // SEVEN NEED NOTHING BUT `read`. The two session slots that write, the two
709
+ // token slots that write, the two clears, and both account slots answer from
710
+ // the shared install record alone.
711
+ //
712
+ // SIX TAKE MORE, and the widenings are worth naming because each is a fact
713
+ // Drawbridge owns that the manifest must not:
714
+ //
715
+ // conversionRate, planTitle what Drawbridge charges this organization.
716
+ // The org list renders both, and importing the
717
+ // plan catalogue here is the cycle
718
+ // features -> connections -> shopify -> plans
719
+ // -> features that was just broken.
720
+ // currencies which settlement currencies Drawbridge can
721
+ // bill. A store outside them fails the
722
+ // connection validator INSIDE the transaction,
723
+ // which surfaces as an opaque 500, so `add`
724
+ // checks first and says so.
725
+ // mintId the connection's id, known before the write
726
+ // that creates it, because the organization's
727
+ // own refs have to name it in the same
728
+ // transaction.
729
+ // shopify the vendor. Four slots call the store: two
730
+ // read the live plan, two release it.
731
+ // mark THE ONE WRITE A DESCRIPTION CANNOT CARRY, and
732
+ // `reselect` is the only slot with one. See its
733
+ // implementation for why the order is the point.
650
734
  'install.account.link' : INSTALL_SIGNATURE,
651
735
  'install.account.unlink' : INSTALL_SIGNATURE,
652
- 'install.organizations.add' : INSTALL_SIGNATURE,
653
- 'install.organizations.list' : INSTALL_SIGNATURE,
736
+ 'install.organizations.add' : Object.freeze({
737
+ options : [ 'currencies', 'mintId', 'read', 'shopify' ],
738
+ props : [ 'context' ]
739
+ }),
740
+ 'install.organizations.list' : Object.freeze({
741
+ options : [ 'conversionRate', 'planTitle', 'read', 'shopify' ],
742
+ props : [ 'context' ]
743
+ }),
654
744
  'install.organizations.remove' : INSTALL_SIGNATURE,
655
- 'install.plan.cancel' : INSTALL_SIGNATURE,
656
- 'install.plan.reselect' : INSTALL_SIGNATURE,
745
+ 'install.plan.cancel' : Object.freeze({ options : [ 'read', 'shopify' ], props : [ 'context' ] }),
746
+ // `manifest` because the handle this charge bills under is the vendor's own
747
+ // declaration (auth.install.billing.handle), and checking the job against the
748
+ // declaration is what the enqueuer-checks-itself version could not do.
749
+ 'install.plan.charge' : Object.freeze({
750
+ options : [ 'shopify' ],
751
+ props : [ 'clientId', 'clientSecret', 'context', 'manifest' ]
752
+ }),
753
+ 'install.plan.reselect' : Object.freeze({ options : [ 'mark', 'read', 'shopify' ], props : [ 'context' ] }),
754
+ 'install.plan.verify' : Object.freeze({ options : [ 'adminToken', 'read', 'shopify' ], props : [ 'context' ] }),
657
755
  'install.session.clear' : INSTALL_SIGNATURE,
658
756
  'install.session.read' : INSTALL_SIGNATURE,
659
757
  'install.session.save' : INSTALL_SIGNATURE,
@@ -1223,6 +1321,301 @@ const surviving = ({ errors = [], proved = [] } = {}) => (
1223
1321
  // as a Mongo "Document failed validation" naming no field.
1224
1322
  const isStatus = ( status ) => STATUSES.includes( status );
1225
1323
 
1324
+ // CARRYING OUT WHAT A HOOK DESCRIBED, and the only place that does it.
1325
+ //
1326
+ // It lived in drawbridge-sync until drawbridge-api needed it too: the install
1327
+ // hooks describe writes, and an api route that performed them itself would be a
1328
+ // second implementation of this file. Two performers is exactly the drift this
1329
+ // package exists to remove — and the subtle half is not the writing, it is the
1330
+ // ORDERING and the transaction, which a second copy would eventually get wrong.
1331
+ //
1332
+ // ENQUEUE AND EMIT ARE INJECTED rather than imported. They are the two effects a
1333
+ // performer cannot own: sync enqueues onto its own BullMQ queues through the
1334
+ // telemetry wrapper and emits through its socket server, and the api has
1335
+ // neither. Everything else — the writes, the duplicate rule, the transaction —
1336
+ // is the same wherever the answer came from.
1337
+
1338
+ const perform = async ({ answer, controller, emit, enqueue }) => {
1339
+
1340
+ const { enqueues, events, transaction, writes } = answerOf( answer ) || {};
1341
+
1342
+ const write = async ( { collection, data, ignoreDuplicate, multiple, operation, options, query }, session ) => {
1343
+
1344
+ // NOTHING IS REBUILT HERE. This used to reconstruct `_id` from a created
1345
+ // document's `id`, because the injected `mintId` handed the manifests a bare
1346
+ // string — a published package has no mongodb dependency and cannot make an
1347
+ // ObjectId. So the pair was minted in one direction here and derived in the
1348
+ // other by drawbridge-mongodb's own `ids()`, which already accepts a supplied
1349
+ // `_id` and derives `id` from it. Two places minting one pair, opposite ways.
1350
+ //
1351
+ // `mintId` now returns BOTH halves and the hook places each, so a described
1352
+ // write arrives already shaped and this is a plain pass-through.
1353
+
1354
+ try {
1355
+
1356
+ await controller[ operation ]({
1357
+ collection,
1358
+ data,
1359
+ // The controller takes `multiple` beside options — an update that
1360
+ // means every matching document must say so explicitly.
1361
+ ...( multiple && { multiple : true }),
1362
+ ...( ( options || session ) && { options : { ...options, ...( session && { session }) } }),
1363
+ ...( query && { query })
1364
+ });
1365
+
1366
+ } catch ( error ){
1367
+
1368
+ // A DUPLICATE IS THE MECHANISM WORKING where the hook said so — the
1369
+ // team-notify damper is a partial unique index, and a collision means this
1370
+ // recipient has already been told inside the bucket. Everywhere else a
1371
+ // duplicate key is a real failure and must still throw.
1372
+ if( ! ( ignoreDuplicate && isDuplicateKey( error ) ) ) throw error;
1373
+
1374
+ }
1375
+
1376
+ };
1377
+
1378
+ // ALL OR NONE when the hook asked for it — the order/redemption pair, where a
1379
+ // half-written attribution is revenue counted twice or not at all. Enqueues
1380
+ // and events stay OUTSIDE and after: a job must not observe rows that later
1381
+ // roll back, and an emit is not transactional anywhere.
1382
+ if( transaction ){
1383
+
1384
+ await controller.transaction( async ( session ) => {
1385
+
1386
+ for( const descriptor of writes ) await write( descriptor, session );
1387
+
1388
+ });
1389
+
1390
+ } else {
1391
+
1392
+ for( const descriptor of writes ) await write( descriptor );
1393
+
1394
+ }
1395
+
1396
+ for( const { data, name, options, queue : target } of enqueues ){
1397
+
1398
+ // ENQUEUE AND EMIT ARE INJECTED, because they are the two things a
1399
+ // performer cannot own. drawbridge-sync enqueues through the telemetry
1400
+ // wrapper onto its own BullMQ queues and emits through its socket server;
1401
+ // drawbridge-api has neither. A hook that describes one where the caller
1402
+ // supplied no way to do it is a programming error, and it says so rather
1403
+ // than dropping the effect on the floor.
1404
+ if( ! enqueue ) throw new Error( 'A hook described an enqueue and this caller supplied no enqueuer' );
1405
+
1406
+ await enqueue({ data, name, options, queue : target });
1407
+
1408
+ }
1409
+
1410
+ for( const descriptor of events ){
1411
+
1412
+ if( ! emit ) throw new Error( 'A hook described a socket event and this caller supplied no emitter' );
1413
+
1414
+ emit( descriptor );
1415
+
1416
+ }
1417
+
1418
+ };
1419
+
1420
+ // WORK DONE ON A STORED CONNECTION ROW, FROM OUTSIDE A HOOK.
1421
+ //
1422
+ // Two things live here, and they are here for the same reason: each needs a
1423
+ // CONTROLLER, which a hook may never hold. A hook describes its effects and
1424
+ // perform() carries them out; these two are the cases where that split does not
1425
+ // fit, so they are the shell's work — but they were the shell's work in
1426
+ // drawbridge-sync, in a `lib/connection.js` that also imported the Shopify
1427
+ // client. That made them a second connections library beside this one.
1428
+ //
1429
+ // They take a controller as an ARGUMENT rather than importing anything, exactly
1430
+ // as perform() does. That is the established shape for a utils file that has to
1431
+ // touch the database.
1432
+
1433
+ // FETCH A CONNECTION AND DECRYPT ITS SETTINGS IN ONE CALL. Callers pass the
1434
+ // whole query — by id, by organization and slug, with $in arrays — and typically
1435
+ // include `status : 'active'`.
1436
+ //
1437
+ // Answers `{ connection, settings }`, both null when nothing matched, so the
1438
+ // call site keeps its terse guard:
1439
+ //
1440
+ // const { connection, settings } = await connectionSettings({ controller, query });
1441
+ // if( ! connection ) return;
1442
+ const connectionSettings = async ({ controller, query }) => {
1443
+
1444
+ const connection = await controller.get({ collection : 'connection', query });
1445
+
1446
+ if( ! connection ) return { connection : null, settings : null };
1447
+
1448
+ return { connection, settings : decrypt( connection.settings ) };
1449
+
1450
+ };
1451
+
1452
+ const SCOPES_ERROR_SOURCE = 'scopes';
1453
+
1454
+ // Merchant-facing text. It must match the entry drawbridge-api presents, so the
1455
+ // two writers converging on one row never change the message; the missing-scope
1456
+ // detail stays in the log.
1457
+ const SCOPES_ERROR_MESSAGE = 'Shopify permissions are out of date. Open the Drawbridge app in your Shopify admin to approve the updated permissions.';
1458
+
1459
+ // SCOPE-DRIFT ENFORCEMENT, SHOP-SCOPED — because every organization's connection
1460
+ // to the same store shares ONE grant.
1461
+ //
1462
+ // WHY THIS IS NOT A HOOK, which is the question it invites. A hook answers for
1463
+ // one connection and describes writes for that connection. This one reads a
1464
+ // grant held on a shared install record and then writes across EVERY
1465
+ // organization's connection to that store, plus their products and
1466
+ // advertisements. And `lifecycle.health` needs its RESULT to compose the message
1467
+ // it returns, which is why the health hook takes it as an injected
1468
+ // `reconcileScopes` rather than calling it.
1469
+ //
1470
+ // THE VENDOR STILL JUDGES ITS OWN GRANT. What is missing from a grant is
1471
+ // `auth.scopes`, asked here through runHook. What to DO about it is this
1472
+ // function. The judging half used to be a direct import of the Shopify client.
1473
+ //
1474
+ // Missing scopes: every connection for the shop goes `status : 'error'` with a
1475
+ // scopes-sourced entry, the store's products deactivate, and their
1476
+ // advertisements draft. Complete scopes: the entry is removed, status returns to
1477
+ // active when nothing else survives, and each product RE-SYNCS through its own
1478
+ // worker — the single reconciliation point — rather than a blanket reactivate,
1479
+ // which would wrongly revive products that are inactive because the merchant
1480
+ // unpublished them from the channel. Ads stay drafted for a deliberate
1481
+ // re-enable, matching the disconnect and unpublish cascades.
1482
+ //
1483
+ // `granted` is the grant to evaluate, a string or an array. Omitted, it is read
1484
+ // from the install record's stored session, which the embedded app rewrites on
1485
+ // every open. NO GRANT DATA AT ALL MEANS SKIP, never enforce: absence of evidence
1486
+ // must not error a healthy connection, and a legacy one-to-one install carries no
1487
+ // install record.
1488
+ const reconcileConnectionScopes = async ({
1489
+ controller,
1490
+ enqueue,
1491
+ granted = null,
1492
+ logger,
1493
+ shop,
1494
+ slug = 'shopify'
1495
+ }) => {
1496
+
1497
+ if( granted === null ){
1498
+
1499
+ const record = await controller.get({ collection : 'shop', query : { shop } });
1500
+
1501
+ const session = record?.settings ? decrypt( record.settings ) : null;
1502
+
1503
+ granted = session?.scope || null;
1504
+
1505
+ }
1506
+
1507
+ if( ! granted ) return null;
1508
+
1509
+ // NULL IS NOT AN EMPTY LIST. The hook answers null when the grant could not be
1510
+ // read at all, and reading that as "nothing missing" is how a connection
1511
+ // reports healthy while it has quietly stopped doing half its job.
1512
+ const answered = await runHook( slug, 'auth.scopes', { scope : granted } );
1513
+
1514
+ const missing = answered.outcome === 'answered' ? answered.answer.result?.missing : null;
1515
+
1516
+ if( ! Array.isArray( missing ) ){
1517
+
1518
+ logger?.warn?.( 'connection.scopes.unreadable', { shop, slug });
1519
+
1520
+ return null;
1521
+
1522
+ }
1523
+
1524
+ const connections = await controller.aggregate({
1525
+ collection : 'connection',
1526
+ pipeline : [ { $match : { shop, slug } } ]
1527
+ });
1528
+
1529
+ let hadScopesError = false;
1530
+
1531
+ for( const connection of connections ){
1532
+
1533
+ const others = ( connection.errors || [] ).filter( ( entry ) => entry.source !== SCOPES_ERROR_SOURCE );
1534
+
1535
+ const hasScopesError = others.length !== ( connection.errors || [] ).length;
1536
+
1537
+ hadScopesError = hadScopesError || hasScopesError;
1538
+
1539
+ if( missing.length ){
1540
+
1541
+ await controller.update({
1542
+ collection : 'connection',
1543
+ data : {
1544
+ $set : {
1545
+ errors : [ ...others, { message : SCOPES_ERROR_MESSAGE, source : SCOPES_ERROR_SOURCE } ],
1546
+ status : 'error'
1547
+ }
1548
+ },
1549
+ query : { id : connection.id }
1550
+ });
1551
+
1552
+ } else if( hasScopesError ){
1553
+
1554
+ await controller.update({
1555
+ collection : 'connection',
1556
+ data : {
1557
+ $set : {
1558
+ errors : others,
1559
+ ...( connection.status === 'error' && ! others.length && { status : 'active' })
1560
+ }
1561
+ },
1562
+ query : { id : connection.id }
1563
+ });
1564
+
1565
+ }
1566
+
1567
+ }
1568
+
1569
+ const products = connections.length
1570
+ ? await controller.aggregate({
1571
+ collection : 'product',
1572
+ pipeline : [ { $match : { 'provider.slug' : slug, 'source.domain' : shop } } ]
1573
+ })
1574
+ : [];
1575
+
1576
+ if( missing.length && products.length ){
1577
+
1578
+ const productIds = products.map( ( product ) => product.id );
1579
+
1580
+ await controller.update({
1581
+ collection : 'product',
1582
+ data : { $set : { status : 'inactive' } },
1583
+ multiple : true,
1584
+ query : { id : { $in : productIds } }
1585
+ });
1586
+
1587
+ await controller.update({
1588
+ collection : 'advertisement',
1589
+ data : { $set : { status : 'drafted' } },
1590
+ multiple : true,
1591
+ query : { product : { $in : productIds } }
1592
+ });
1593
+
1594
+ } else if( ! missing.length && hadScopesError && enqueue ){
1595
+
1596
+ // ENQUEUE IS INJECTED, for the same reason perform()'s is: this package
1597
+ // holds no BullMQ queues, and a caller that supplied no enqueuer gets the
1598
+ // writes without the re-sync rather than a crash. The health path supplies
1599
+ // one; a caller that only wants the status corrected need not.
1600
+ for( const product of products ){
1601
+
1602
+ await enqueue({
1603
+ data : { product : product.id },
1604
+ name : 'sync',
1605
+ options : { jobId : 'product.' + slug + '.' + product.id + '.' + Date.now() },
1606
+ queue : 'product.' + slug
1607
+ });
1608
+
1609
+ }
1610
+
1611
+ }
1612
+
1613
+ logger?.info?.( 'connection.scopes.reconciled', { connections : connections.length, missing, shop });
1614
+
1615
+ return missing;
1616
+
1617
+ };
1618
+
1226
1619
  // THE WORKFLOWS A CONNECTION OWNS, provisioned from its manifest.
1227
1620
  //
1228
1621
  // It lived in drawbridge-sync until it was needed twice: sync provisions lazily
@@ -1895,9 +2288,18 @@ var scaffold = {
1895
2288
  commerce : {
1896
2289
  // NEW in phase 1 — the Checkout Kit cart path, which writes nothing and is
1897
2290
  // the last api surface Shopify owns outside a hook.
1898
- cart : async ( { connection, context }, { read } = {} ) => ANSWER,
2291
+ // NO `connection` AND NO `step`: this one runs for a SHOPPER on a public
2292
+ // page, not for an organization inside a workflow. It reads the storefront
2293
+ // with the store's storefront token and mints a checkout token from
2294
+ // DRAWBRIDGE'S app credentials, which is why the credential pair rides in
2295
+ // as props — the way auth.disconnect already carries it.
2296
+ cart : async ( { clientId, clientSecret, context, settings }, { fetcher, shopify } = {} ) => ANSWER,
1899
2297
  code : async ( { connection, context, step }, { adminToken, shopify } = {} ) => ANSWER,
1900
2298
  customer : async ( { connection, context }, { adminToken, read, shopify } = {} ) => ANSWER,
2299
+ // STOCK, READ AT RENDER TIME rather than stored. It is the one product
2300
+ // fact that is stale the instant it is written down, so the campaign
2301
+ // product list asks the vendor and caches the answer for a minute.
2302
+ inventory : async ( { connection, context }, { adminToken, shopify } = {} ) => ANSWER,
1901
2303
  order : async ( { connection, context }, { logger, mintId, read } = {} ) => ANSWER,
1902
2304
  // THIS IS WHERE `product.insert` LANDS.
1903
2305
  //
@@ -1919,7 +2321,7 @@ var scaffold = {
1919
2321
  // nothing and shows up only as `workflow.dispatch.candidates matched : 0` in
1920
2322
  // a log. Phase 1 closes it and build() checks every trigger against it.
1921
2323
  product : {
1922
- hook : async ( { connection, context, workflow }, { mintId, read } = {} ) => ANSWER,
2324
+ hook : async ( { connection, context, settings, workflow }, { adminToken, mintId, read, shopify } = {} ) => ANSWER,
1923
2325
  step : () => ({
1924
2326
  description : 'Syncs product data from the vendor when a product changes.',
1925
2327
  key : 'Vendor Product Sync',
@@ -2067,8 +2469,19 @@ var scaffold = {
2067
2469
  // Vendor billing, the only group that is not generic. Pairs with
2068
2470
  // auth.install.billing, which declares what the merchant is being charged.
2069
2471
  plan : {
2070
- cancel : async ( { context }, { dispatch, read } = {} ) => ANSWER,
2071
- reselect : async ( { context }, { dispatch, read } = {} ) => ANSWER
2472
+ cancel : async ( { context }, { read, shopify } = {} ) => ANSWER,
2473
+ // NOT A MERCHANT ACTION EITHER one attributed order's usage charge,
2474
+ // sent to the vendor's meter. The credential pair rides in as props
2475
+ // because the charge authenticates as the APP, not as the store.
2476
+ charge : async ( { clientId, clientSecret, context, manifest }, { shopify } = {} ) => ANSWER,
2477
+ // `mark` is the one write a description cannot carry: the marker must
2478
+ // be persisted BEFORE the approval is released, and a described write
2479
+ // lands after the hook returns.
2480
+ reselect : async ( { context }, { mark, read, shopify } = {} ) => ANSWER,
2481
+ // NOT A MERCHANT ACTION — the question "is this store's billing
2482
+ // actually metered right now", asked by the daily health check and by
2483
+ // the vendor's own approval webhook, which used to hand-copy it.
2484
+ verify : async ( { context }, { adminToken, read, shopify } = {} ) => ANSWER
2072
2485
  },
2073
2486
  // The embedded app's own session, stored by us on the vendor's behalf.
2074
2487
  session : {
@@ -2711,16 +3124,19 @@ var attentive = {
2711
3124
  'Anyone who has opted out in Drawbridge is sent to Attentive as an unsubscribe rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.',
2712
3125
  'Attentive accepts these updates and applies them in the background, so a contact appears in your segment shortly after the sync rather than the instant it runs.'
2713
3126
  ],
2714
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
2715
- // The dashboard already carries generic reasons for every way an
2716
- // authorization can fail — declined, expired state, a vendor we are not set
2717
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
2718
- // failures need saying differently. This one's do not.
3127
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
3128
+ // consent screen has two ways to end: they said no, or something between
3129
+ // here and the vendor went wrong.
2719
3130
  //
2720
- // Answered rather than omitted, because `errors` existed on two of six
2721
- // manifests and there was no way to tell "nothing to add" from "nobody
2722
- // wrote it".
2723
- errors : false,
3131
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
3132
+ // authorization"; a merchant connecting two accounts in one sitting needs
3133
+ // to know which one this was.
3134
+ errors : {
3135
+ connect : {
3136
+ denied : 'The Attentive authorization was declined, so nothing was connected.',
3137
+ invalid : 'We couldn\'t complete the Attentive connection. Try connecting again.'
3138
+ }
3139
+ },
2724
3140
  excerpt : 'Sync your Drawbridge contacts into an Attentive segment.',
2725
3141
  // NAMES THE SEGMENT STEP, because `status` below gates on it: a grant with
2726
3142
  // no segment chosen sits at Pending, and a guide that stops at the consent
@@ -4255,15 +4671,12 @@ var drawbridge = {
4255
4671
  description : [
4256
4672
  'Drawbridge sends your notification emails and your entrants\' emails, and recalculates segment membership on a schedule. It is always on and needs nothing connected.'
4257
4673
  ],
4258
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
4259
- // The dashboard already carries generic reasons for every way an
4260
- // authorization can fail declined, expired state, a vendor we are not set
4261
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
4262
- // failures need saying differently. This one's do not.
4674
+ // NOTHING IS EVER CONNECTED, so no connect can fail. This is the platform's
4675
+ // own manifest always on, private, never in the catalogue — and it exists
4676
+ // to give Drawbridge's own steps a hook to point at. `denied` and `invalid`
4677
+ // describe a consent screen this connection does not have.
4263
4678
  //
4264
- // Answered rather than omitted, because `errors` existed on two of six
4265
- // manifests and there was no way to tell "nothing to add" from "nobody
4266
- // wrote it".
4679
+ // `false` is the recorded decision, not silence.
4267
4680
  errors : false,
4268
4681
  excerpt : 'The steps Drawbridge runs itself.',
4269
4682
  guide : [
@@ -6607,16 +7020,19 @@ var mailchimp = {
6607
7020
  'Anyone who has opted out in Drawbridge is synced as unsubscribed rather than omitted, so a person who asked not to be contacted stays suppressed in both systems instead of quietly reappearing.',
6608
7021
  'Someone who unsubscribed inside Mailchimp keeps that choice: a resync only sets the status of a subscriber Mailchimp has never seen before.'
6609
7022
  ],
6610
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
6611
- // The dashboard already carries generic reasons for every way an
6612
- // authorization can fail — declined, expired state, a vendor we are not set
6613
- // up for — and a sentence here is an ADDITION to those, for a vendor whose
6614
- // failures need saying differently. This one's do not.
7023
+ // WHAT THE MERCHANT READS WHEN A CONNECT FAILS. Two states, because the
7024
+ // consent screen has two ways to end: they said no, or something between
7025
+ // here and the vendor went wrong.
6615
7026
  //
6616
- // Answered rather than omitted, because `errors` existed on two of six
6617
- // manifests and there was no way to tell "nothing to add" from "nobody
6618
- // wrote it".
6619
- errors : false,
7027
+ // THE VENDOR IS NAMED IN BOTH. The dashboard's generic copy says "the
7028
+ // authorization"; a merchant connecting two accounts in one sitting needs
7029
+ // to know which one this was.
7030
+ errors : {
7031
+ connect : {
7032
+ denied : 'The Mailchimp authorization was declined, so nothing was connected.',
7033
+ invalid : 'We couldn\'t complete the Mailchimp connection. Try connecting again.'
7034
+ }
7035
+ },
6620
7036
  excerpt : 'Sync your Drawbridge contacts into a Mailchimp audience.',
6621
7037
  // NAMES THE AUDIENCE STEP, because `status` below gates on it: a grant with
6622
7038
  // no audience chosen sits at Pending, and a guide that stops at the consent
@@ -7434,6 +7850,28 @@ var icon = `<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmln
7434
7850
  <path d="M300.325 382.771L368 365.96C368 365.96 338.904 169.185 338.689 167.892C338.473 166.599 337.396 165.737 336.318 165.737C335.24 165.737 316.274 165.306 316.274 165.306C316.274 165.306 304.636 154.099 300.325 149.788V382.771Z" fill="white"/>
7435
7851
  </svg>`;
7436
7852
 
7853
+ // THE CLIENT, IMPORTED AND DEFAULTED — the same shape as `fetcher = fetch`
7854
+ // everywhere else in this package.
7855
+ //
7856
+ // It used to be injected because @drawbridge/shopify depended on this package,
7857
+ // so this file could not import it. That cycle is gone: the client lives in
7858
+ // lib/shopify/ now. But the injection had a SECOND job nobody wrote down — it is
7859
+ // the test seam, and it is at the right level. The hook tests stub
7860
+ // getActiveAppSubscriptions and getUsageChargeEvents and assert the arguments
7861
+ // they were called with; stubbing raw HTTP instead would mean hand-building
7862
+ // Shopify GraphQL envelopes and would lose those assertions entirely.
7863
+ //
7864
+ // So the default is the real client and callers inject nothing. Production
7865
+ // passes no `shopify` option at all — drawbridge-api and drawbridge-sync have
7866
+ // stopped wiring one — and a test overrides exactly the namespace it needs.
7867
+ const client = Object.freeze({
7868
+ admin : shopifyAdmin,
7869
+ billing : shopifyBilling,
7870
+ oauth : shopifyOauth,
7871
+ partner : shopifyPartner,
7872
+ storefront : shopifyStorefront
7873
+ });
7874
+
7437
7875
  // ── ORDER ATTRIBUTION, the vendor's own arithmetic ───────────────────────────
7438
7876
  //
7439
7877
  // Orders are attributed via `_drwbrdg_*` line-item properties injected at
@@ -7553,7 +7991,7 @@ const discountWarning = ( discount ) => {
7553
7991
  // equal, case-sensitively, the meter HANDLE configured on the app's plan in
7554
7992
  // the Dev Dashboard (the plan editor's Handle field — display names prove
7555
7993
  // nothing) and the SHOPIFY_USAGE_ORDERS_EVENT_HANDLE default in
7556
- // @drawbridge/shopify, which this package cannot import. This is the single
7994
+ // the Shopify client, which lives in this package now. This is the single
7557
7995
  // mint point on the utils side: the transaction id is prefixed with it, the
7558
7996
  // billing enqueue carries it, and the manifest publishes it under
7559
7997
  // auth.install.billing.handle — so drawbridge-sync can refuse to send an event whose
@@ -7624,1032 +8062,2509 @@ const COMPLIANCE_TOPICS = new Set([
7624
8062
  'shop/redact'
7625
8063
  ]);
7626
8064
 
7627
- // Shopify installed from the App Store, never connected with keys.
7628
- var shopify = {
8065
+ // ── THE EMBEDDED-APP SURFACE ─────────────────────────────────────────────────
8066
+ //
8067
+ // Everything from here to the end of this block serves `hooks.install`, which
8068
+ // is what api/route/shopify-embedded.js became: 1,386 lines and thirteen routes
8069
+ // whose paths all began `/shopify/embedded/` — a vendor's name in a url, on a
8070
+ // surface no other connection could ever reach.
8071
+
8072
+ // A REFUSAL WITH A STATUS. runHook already carries `error.status` out to the
8073
+ // caller for exactly this — inbound.verify answers 401 on a forged signature the
8074
+ // same way — so the install slots say 402, 403, 409, 422 or 502 in the same
8075
+ // breath as the reason, and the route answers what they said rather than folding
8076
+ // every rejection into a 500.
8077
+ const refusal = ( message, status ) => Object.assign( new Error( message ), { status });
8078
+
8079
+ // THIRTY MINUTES for the one-shot link token. Long enough for a merchant to
8080
+ // finish a consent screen, short enough that a stranded token is not a standing
8081
+ // key to a store.
8082
+ const LINK_TOKEN_TTL_MS = 30 * 60 * 1000;
8083
+
8084
+ // The statuses the connection-write gate accepts, mirroring the dashboard's
8085
+ // own list on every organization-connection route. Read by BOTH `add` and
8086
+ // `list`, so the list cannot tell a merchant an org is connectable and then
8087
+ // have `add` refuse it.
8088
+ const CONNECTABLE_STATUSES = [ 'active', 'unsubscribed' ];
8089
+
8090
+ // THE MERCHANT'S OWN ORGANIZATIONS — owned, or joined with the `connections`
8091
+ // capability at `manage`. Shared by the list and the two org slots that gate on
8092
+ // access, and each org carries its Shopify connection ({ id, shop, status }) so
8093
+ // the one-store-per-organization rule is answered in the same round trip.
8094
+ const merchantOrganizations = ({ read, user }) => read.aggregate({
8095
+ collection : 'organization',
8096
+ pipeline : [
8097
+ // A member qualifies only at `manage`; an owner always qualifies.
8098
+ {
8099
+ $lookup : {
8100
+ as : 'membership',
8101
+ from : 'member',
8102
+ let : { organization : '$id' },
8103
+ pipeline : [
8104
+ {
8105
+ $match : {
8106
+ $expr : { $eq : [ '$organization', '$$organization' ] },
8107
+ 'capabilities.connections' : 'manage',
8108
+ status : 'accepted',
8109
+ user
8110
+ }
8111
+ },
8112
+ { $limit : 1 },
8113
+ { $project : { _id : 0, organization : 1 } }
8114
+ ]
8115
+ }
8116
+ },
8117
+ {
8118
+ $match : {
8119
+ status : { $ne : 'canceled' },
8120
+ $or : [ { owner : user }, { 'membership.0' : { $exists : true } } ]
8121
+ }
8122
+ },
8123
+ // `organization.subscription` is a subscription RECORD ID — resolved here so
8124
+ // the list can read the real plan and conversion rate. Without it every org
8125
+ // rendered as free tier.
8126
+ { $lookup : { as : 'subscription', foreignField : 'id', from : 'subscription', localField : 'subscription' } },
8127
+ { $unwind : { path : '$subscription', preserveNullAndEmptyArrays : true } },
8128
+ {
8129
+ $lookup : {
8130
+ as : 'connection',
8131
+ foreignField : 'organization',
8132
+ from : 'connection',
8133
+ localField : 'id',
8134
+ pipeline : [
8135
+ { $match : { slug : 'shopify' } },
8136
+ { $limit : 1 },
8137
+ { $project : { _id : 0, id : 1, shop : 1, status : 1 } }
8138
+ ]
8139
+ }
8140
+ },
8141
+ { $unwind : { path : '$connection', preserveNullAndEmptyArrays : true } },
8142
+ { $project : { _id : 0, billingStatus : '$status', connection : 1, id : 1, name : '$title', subscription : 1 } },
8143
+ { $sort : { name : 1 } }
8144
+ ]
8145
+ });
7629
8146
 
7630
- // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
7631
- // a BUTTON LABEL inside the copy object next to a url — the title and the
7632
- // address it belongs to sat at different levels and could drift apart.
7633
- //
7634
- // It names where the link GOES rather than what it does: installing happens on
7635
- // the App Store listing, and the dashboard must never imply a store can be
7636
- // linked from inside it.
7637
- actions : {
7638
- create : null,
7639
- listing : 'View on the Shopify App Store',
7640
- manage : null,
7641
- update : null
7642
- },
7643
- // INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
7644
- // sees a consent screen we sent them to -- they start at the App Store, and
7645
- // the install completes inside Shopify admin without redirecting back. A
7646
- // Connect button here would be lying about where connecting happens.
7647
- auth : {
8147
+ // THE STORE'S LIVE APP PRICING STATUS. getActiveAppSubscriptions returns only
8148
+ // ACTIVE subscriptions, so a non-empty list means the merchant approved a plan.
8149
+ //
8150
+ // BEST-EFFORT, AND THE FAILURE IS NAMED. "No plan" and "couldn't check" are
8151
+ // different answers `add` returns a retryable 502 for the latter rather than
8152
+ // telling a paying merchant to approve a plan.
8153
+ const storePlan = async ({ record, shopify }) => {
7648
8154
 
7649
- // ONE STORE PER ORGANIZATION. A Shopify install IS a store, and a second
7650
- // one would give an organization two catalogues, two order streams and two
7651
- // usage meters with nothing to say which is authoritative.
7652
- //
7653
- // DECLARED AND ENFORCED BY NOTHING, measured 2026-09-13: no repo reads this
7654
- // key, and the connection collection's { organization, slug } index is not
7655
- // unique. So a second Shopify connection would be accepted today. The gate
7656
- // belongs at the connect route with a unique partial index behind it, and
7657
- // that is api work — this is the declaration it will read.
7658
- exclusive : true,
7659
- generated : false,
8155
+ const settings = record?.settings ? decrypt( record.settings ) : null;
7660
8156
 
7661
- // THE MERCHANT STARTS AT SHOPIFY, not here. They install the app from
7662
- // Shopify's own admin or app store and the grant is exchanged inside that
7663
- // flow — there is no consent screen we sent them to, which is the whole
7664
- // reason this is not `oauth`. A Connect button would be lying.
7665
- //
7666
- // Both members are install-only, which is exactly why they could not live at
7667
- // the top of `auth`.
7668
- install : {
8157
+ if( ! settings?.accessToken ) return { active : false, name : null };
7669
8158
 
7670
- // SHOPIFY APP PRICING. Was the top-level `events` key, which it never
7671
- // was: `events.order` is not an event vocabulary, it is a usage meter
7672
- // handle plus the capped plan a merchant approves. It reads correctly
7673
- // beside the install it belongs to, and it is the only manifest that
7674
- // has one.
7675
- billing : {
7676
- handle : ORDER_EVENT_HANDLE,
7677
- // THE SUBSCRIPTION THE APP MINTS FOR ITSELF (Billing API, not the
7678
- // managed-pricing meter system): a $0 recurring line plus this usage
7679
- // line, approved by the org owner at Shopify's confirmation screen.
7680
- // `terms` is the merchant-visible consent copy; "up to" because the
7681
- // actual rate is plan-derived (conversionRate) and must never exceed
7682
- // what was approved. cappedAmount bounds one 30-day cycle's usage
7683
- // charges — a record beyond it errors until the merchant raises it.
7684
- // ponytail: $1,000 cap ≈ $66k attributed revenue/cycle at 1.5%; raise
7685
- // via a re-approved subscription when a merchant outgrows it.
7686
- pricing : {
7687
- cappedAmount : 1000,
7688
- currency : 'usd',
7689
- name : 'Drawbridge Orders',
7690
- terms : 'Up to 1.5% of revenue from orders attributed to Drawbridge campaigns'
7691
- }
7692
- },
8159
+ try {
7693
8160
 
7694
- // ONE GRANT BACKS EVERY ORGANIZATION'S CONNECTION TO ONE STORE, and today
7695
- // that is carried by a magic `settings.ref === 'shop'` pointer plus
7696
- // hardcoded slug checks in drawbridge-sync. Declared here so it stops being
7697
- // magic, and so the token path can be told what it is dealing with: a
7698
- // shared credential has many writers by definition, refreshAdminToken
7699
- // already does compare-and-swap on the encrypted blob, and utils' generic
7700
- // save is a plain $set that would clobber a concurrent rotation.
7701
- //
7702
- // NOTHING READS IT YET. The pointer is still the mechanism until the
7703
- // embedded routes become install hooks and the `shop` collection is renamed
7704
- // — this is the declaration those will read, put here now because the block
7705
- // it belongs in exists now.
7706
- shared : {
8161
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
8162
+ adminAccessToken : settings.accessToken,
8163
+ domain : record.shop
8164
+ });
7707
8165
 
7708
- // WHERE THE ONE CREDENTIAL LIVES. Every organization's connection to the
7709
- // same store points at the same row.
7710
- collection : 'shop',
8166
+ const active = ( subscriptions || [] ).length > 0;
7711
8167
 
7712
- // The settings key holding the lookup value, which is also the field it
7713
- // matches on that row — `connection.settings.shop` finds `shop.shop`.
7714
- key : 'shop',
8168
+ // A USAGE LINE ON THE APPROVAL IS POSITIVE EVIDENCE (metered true); its
8169
+ // ABSENCE IS NO VERDICT (null). Measured 2026-09-04 on both 2026-04 and
8170
+ // unstable: managed-pricing App-Events meters never materialize as
8171
+ // subscription line items, so absence must not drive the App Home banner.
8172
+ // `false` is reserved for a signal that can actually observe the meter —
8173
+ // Partner-ledger accrual, which sync stamps onto the connection.
8174
+ const metered = active && subscriptions.some( ( subscription ) => subscription.metered === true ) ? true : null;
7715
8175
 
7716
- // AND HOW A CONNECTION SAYS ITS CREDENTIAL IS SHARED: settings[ marker ]
7717
- // equals the collection name. Today that is the magic
7718
- // `settings.ref === 'shop'` pointer, written at
7719
- // api/route/shopify-embedded.js and read by hardcoded slug checks in
7720
- // three repos. Declared, it stops being magic.
7721
- marker : 'ref'
8176
+ return { active, metered, name : active ? subscriptions[ 0 ]?.name || null : null };
7722
8177
 
7723
- }
8178
+ } catch ( _ ) {
7724
8179
 
7725
- },
7726
- keys : false,
7727
- none : false,
8180
+ return { active : false, name : null, unavailable : true };
7728
8181
 
7729
- // Shopify's install grant is an OAuth exchange under the hood and is NOT
7730
- // declared here: it runs inside the app flow rather than through the shared
7731
- // runner, so a complete auth.oauth block would describe a flow this
7732
- // connection never takes. That disagreement is what the old enum could not
7733
- // prevent `type : 'install'` beside a stale auth.oauth was legal.
7734
- oauth : false
7735
- },
7736
- // EVERYTHING A MERCHANT READS.
7737
- //
7738
- // `errors` is in here rather than at the top level, and that is not a
7739
- // preference: the connection DOCUMENT carries its own `errors` array of
7740
- // scope-drift entries, and the document is spread OVER the resolved manifest
7741
- // downstream a top-level one would be replaced by that array and never
7742
- // render.
7743
- //
7744
- // `connect` no longer exists as a container. Its other member was `redirect`,
7745
- // which is a URL and now sits with the vendor's other addresses.
7746
- content : {
7747
- confirm : 'Disconnecting deactivates all products from this store, drafts any advertisements that use them, disables Shopify steps in your workflows until you reconnect, and stops revenue tracking for this organization.',
7748
- description : [
7749
- 'Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.',
7750
- 'Drawbridge attributes orders that originate from your campaigns — matched through cart parameters and lead-mapped discount codes — so you can see the revenue each campaign drives.',
7751
- 'Drawbridge reads your products, records orders placed through your campaigns, and can issue discount codes. Order and product updates reach Drawbridge through the app\'s own webhooks, which Shopify applies when the app is installed.'
7752
- ],
7753
- errors : {
7754
- connect : {
7755
- conflict : 'This store is already connected to another Drawbridge organization.',
7756
- currency : 'This store settles in a currency we can\'t bill yet. Connect a store with a supported settlement currency.',
7757
- invalid : 'We couldn\'t verify the install. Please try connecting again from the Shopify App Store.'
8182
+ }
8183
+
8184
+ };
8185
+
8186
+ // RELEASE EVERY ACTIVE APPROVAL for this app on this store. Shared by `cancel`
8187
+ // and `reselect`, which differ only in what happens before and what it means —
8188
+ // the mechanics are identical, and two copies of a billing mutation is how they
8189
+ // come to disagree.
8190
+ const releaseApprovals = async ({ context, read, shopify }) => {
8191
+
8192
+ const shop = sanitizeDomain( context?.shop );
8193
+
8194
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8195
+
8196
+ const record = await read.get({ collection : 'shop', query : { shop } });
8197
+
8198
+ const settings = record?.settings ? decrypt( record.settings ) : null;
8199
+
8200
+ if( ! settings?.accessToken ) throw refusal( 'Store is not installed', 409 );
8201
+
8202
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
8203
+ adminAccessToken : settings.accessToken,
8204
+ domain : shop
8205
+ });
8206
+
8207
+ for( const subscription of ( subscriptions || [] ) ){
8208
+
8209
+ const data = await shopify.admin.adminFetch({
8210
+ adminAccessToken : settings.accessToken,
8211
+ domain : shop,
8212
+ query : 'mutation appSubscriptionCancel( $id : ID! ) { appSubscriptionCancel( id : $id ) { appSubscription { id status } userErrors { field message } } }',
8213
+ variables : { id : subscription.id }
8214
+ });
8215
+
8216
+ const [ userError ] = data?.appSubscriptionCancel?.userErrors || [];
8217
+
8218
+ // SHOPIFY'S OWN WORDS, not ours. A cancel Shopify refused is a merchant-
8219
+ // actionable fact and 422 is the honest code for it.
8220
+ if( userError ) throw refusal( userError.message, 422 );
8221
+
8222
+ }
8223
+
8224
+ return {
8225
+ message : 'Released ' + ( subscriptions || [] ).length + ' approval(s).',
8226
+ request : { shop },
8227
+ result : { ok : true }
8228
+ };
8229
+
8230
+ };
8231
+
8232
+ // ── THE METER VERDICT, ASKED ONCE ────────────────────────────────────────────
8233
+ //
8234
+ // IS THIS STORE'S ORDER BILLING ACTUALLY WORKING? Two signals, and the rule for
8235
+ // reading them is the subtle part — subtle enough that drawbridge-sync's
8236
+ // approval webhook hand-copied it, said so in its own comment twice, and copied
8237
+ // it slightly wrong. One copy now, here, beside the vendor it is about.
8238
+ //
8239
+ // A USAGE LINE on the approval is POSITIVE evidence and gets stamped. Its
8240
+ // ABSENCE PROVES NOTHING: measured 2026-09-04, on both 2026-04 and unstable
8241
+ // and against a plan whose meter exists, managed-pricing App-Events meters
8242
+ // never materialize as subscription line items — AppUsagePricing is the
8243
+ // legacy usage-charge model. Reading absence as "unmetered" flagged healthy
8244
+ // stores, so absence is NO VERDICT: the stamp, the Issue, the card and the
8245
+ // meter-missing pager all stay silent.
8246
+ //
8247
+ // PARTNER-LEDGER ACCRUAL is the real signal. A CHARGE_USAGE event for this
8248
+ // shop is proof billing works, and its charge id becomes the stamp — it
8249
+ // overrides the line-item probe, which managed pricing keeps blind. A usage
8250
+ // ROW exists per subscription even before anything accrues (measured: a
8251
+ // pre-meter approval shows qty 0), so the row is not the proof; a POSITIVE
8252
+ // usageQuantity is, and the LATEST positive row is the stamp.
8253
+ //
8254
+ // `undefined` means no verdict this run — never `null`, which is the health
8255
+ // check's word for "probed, and nothing is accruing".
8256
+ //
8257
+ // The boundaries come back with it because each charge row is also a billing
8258
+ // BOUNDARY the health check reconciles the books against, and re-reading the
8259
+ // ledger to get them would be a second call for data this one already holds.
8260
+ const LEDGER_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
8261
+
8262
+ const meterVerdict = async ({ adminAccessToken, ledgerFloor, partner, shop, shopId, shopify }) => {
8263
+
8264
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({ adminAccessToken, domain : shop });
8265
+
8266
+ let metered = subscriptions.find( ( subscription ) => subscription.usageLineItemId )?.usageLineItemId;
8267
+
8268
+ // Off entirely until the provider row carries the Partner credentials.
8269
+ // `ledger` says whether it was consulted at all, because "read it and found
8270
+ // nothing accruing" and "could not read it" are different answers and only
8271
+ // the first is evidence about the store.
8272
+ if( ! ( partner?.partnerToken && partner?.partnerOrgId && partner?.partnerAppId && shopId ) ){
8273
+
8274
+ return { boundaries : [], ledger : false, metered };
8275
+
8276
+ }
8277
+
8278
+ const accruals = await shopify.partner.getUsageChargeEvents({
8279
+ appId : 'gid://shopify/App/' + partner.partnerAppId,
8280
+ first : 10,
8281
+ occurredAtMin : ledgerFloor.toISOString(),
8282
+ organizationId : partner.partnerOrgId,
8283
+ partnerToken : partner.partnerToken,
8284
+ shopId : 'gid://shopify/Shop/' + shopId
8285
+ });
8286
+
8287
+ // Oldest first: each charge row closes the window since the row before it.
8288
+ const boundaries = ( accruals || [] )
8289
+ .filter( ( event ) => event.occurredAt )
8290
+ .sort( ( a, b ) => new Date( a.occurredAt ) - new Date( b.occurredAt ) );
8291
+
8292
+ const accrued = [ ...boundaries ].reverse().find( ( event ) => Number( event.usageQuantity ) > 0 );
8293
+
8294
+ if( accrued ) metered = String( accrued.chargeId || accrued.id );
8295
+
8296
+ return { boundaries, ledger : true, metered };
8297
+
8298
+ };
8299
+
8300
+ // ── PRODUCT SYNC, the pull half ──────────────────────────────────────────────
8301
+ //
8302
+ // Everything below served drawbridge-sync's queue/product.js — 443 lines and two
8303
+ // BullMQ workers that imported three of this vendor's client modules directly.
8304
+ // It was a shadow implementation of the `commerce.product` step this manifest
8305
+ // already declared, and the step had no trigger, so the declaration and the
8306
+ // thing doing the work had never met.
8307
+
8308
+ // A Storefront variant edge as the product row stores it.
8309
+ const toVariant = ( edge ) => ({
8310
+ availableForSale : edge.node.availableForSale || false,
8311
+ currency : edge.node.price?.currencyCode || null,
8312
+ id : edge.node.id,
8313
+ image : edge.node.image?.url || null,
8314
+ price : parseFloat( edge.node.price?.amount ) || null,
8315
+ requiresShipping : edge.node.requiresShipping || false,
8316
+ sale : parseFloat( edge.node.compareAtPrice?.amount ) || null,
8317
+ title : edge.node.title !== 'Default Title' ? edge.node.title : null
8318
+ });
8319
+
8320
+ // REAL PURCHASABILITY, OVERLAID ON THE STOREFRONT'S CLAIM. availableForSale can
8321
+ // report true for stock checkout cannot actually fulfil — inventory at a
8322
+ // fulfillment-service location that cannot fulfil an online order — so a
8323
+ // throwaway cart is probed for Shopify's MERCHANDISE_OUT_OF_STOCK warnings.
8324
+ //
8325
+ // NEVER THROWS. A failed probe leaves the vendor's own answer standing, which is
8326
+ // the honest fallback: the overlay only ever removes availability it can prove
8327
+ // is wrong.
8328
+ const applyOutOfStock = async ({ domain, shopify, storefrontAccessToken, variants }) => {
8329
+
8330
+ try {
8331
+
8332
+ const outOfStock = new Set( await shopify.storefront.probeVariantsOutOfStock({
8333
+ domain,
8334
+ storefrontAccessToken,
8335
+ variantIds : variants.map( ( variant ) => variant.id )
8336
+ }) );
8337
+
8338
+ return variants.map( ( variant ) => ({
8339
+ ...variant,
8340
+ availableForSale : variant.availableForSale && ! outOfStock.has( variant.id )
8341
+ }) );
8342
+
8343
+ } catch ( _ ){
8344
+
8345
+ return variants;
8346
+
8347
+ }
8348
+
8349
+ };
8350
+
8351
+ // BEST-EFFORT PRODUCT ResourceFeedback — the required-action message on the
8352
+ // product page in the Shopify admin, which the sales-channel requirements say is
8353
+ // where a channel communicates a product's problems.
8354
+ //
8355
+ // Feedback must never fail the sync, and a failure must be LOUD. It is reported
8356
+ // through the caller's error channel rather than a warning: a warn only reaches
8357
+ // Sentry Logs, which is how a feedback path failing on every send since the
8358
+ // channel shipped stayed invisible through two rounds of QA.
8359
+ // sendProductResourceFeedback answers null rather than throwing for the expected
8360
+ // "nothing to attach it to" case, so anything that lands here is a real fault.
8361
+ const sendFeedback = async ({ adminToken, connection, messages, productId, shopify, state }) => {
8362
+
8363
+ try {
8364
+
8365
+ await shopify.admin.sendProductResourceFeedback({
8366
+ adminAccessToken : await adminToken(),
8367
+ domain : connection.shop,
8368
+ messages,
8369
+ productId,
8370
+ state
8371
+ });
8372
+
8373
+ return null;
8374
+
8375
+ } catch ( error ){
8376
+
8377
+ return error;
8378
+
8379
+ }
8380
+
8381
+ };
8382
+
8383
+ // BY PROVIDER IDENTITY WHEN THE DOC ID MISSES. The webhook branch upserts the row
8384
+ // and queues this pull in one described answer, so under a concurrent redelivery
8385
+ // the id it minted may be the one that LOST the upsert race — the row exists,
8386
+ // under the winner's id. The provider id and the shop are stable either way.
8387
+ const resolveProduct = async ({ connection, context, read }) => {
8388
+
8389
+ const byId = context?.product ? await read.get({ collection : 'product', query : { id : context.product } }) : null;
8390
+
8391
+ const row = byId || ( context?.providerId && ( context?.shop || connection?.shop )
8392
+ ? await read.get({
8393
+ collection : 'product',
8394
+ query : {
8395
+ 'provider.id' : context.providerId,
8396
+ 'provider.slug' : 'shopify',
8397
+ 'source.domain' : context.shop || connection.shop
8398
+ }
8399
+ })
8400
+ : null );
8401
+
8402
+ return row;
8403
+
8404
+ };
8405
+
8406
+ // THE MESSAGE, and its format is enforced by Shopify rather than chosen: one
8407
+ // message, at most 100 characters, leading capital, trailing period, not all
8408
+ // caps — a breach fails the whole send.
8409
+ //
8410
+ // Shopify only carries this for a product STILL published to our channel, so
8411
+ // "publish this product" is advice the reader has already followed by the time
8412
+ // they can read it. The one state where it renders is a product the channel
8413
+ // holds but the Storefront will not return: excluded from the channel's US
8414
+ // market. Drafting does not qualify — drafting removes the product from the
8415
+ // channel, Shopify refuses the send, and the embedded app's publishing section
8416
+ // is that product's surface instead. Both causes stay named, because the
8417
+ // merchant reading it cannot tell which applies.
8418
+ const UNAVAILABLE_FEEDBACK = 'Drawbridge can\'t display this product. Check it is active and available in the United States.';
8419
+
8420
+ // The Storefront's contract for a product it cannot see — not published to the
8421
+ // Drawbridge channel, or deleted. True deletions arrive on products/delete.
8422
+ const NOT_FOUND = 'Shopify product not found';
8423
+
8424
+ const syncProduct = async ({ adminToken, connection, context, read, settings, shopify }) => {
8425
+
8426
+ const row = await resolveProduct({ connection, context, read });
8427
+
8428
+ if( ! row ) return { message : 'No product row to sync.', request : { product : context?.product || null }, skipped : true };
8429
+
8430
+ const domain = connection.shop;
8431
+ const providerId = row.provider?.id;
8432
+ const request = { product : row.id, providerId : providerId || null, shop : domain };
8433
+
8434
+ let product;
8435
+
8436
+ try {
8437
+
8438
+ product = await shopify.storefront.getProduct({
8439
+ domain,
8440
+ productId : providerId,
8441
+ storefrontAccessToken : settings?.storefrontAccessToken
8442
+ });
8443
+
8444
+ } catch ( error ){
8445
+
8446
+ // SOFT STATE, NOT TEARDOWN. The row goes inactive but keeps its campaign
8447
+ // references, its ads are drafted — they cannot sell an invisible product —
8448
+ // and the merchant gets product-page feedback. A later successful sync
8449
+ // reverses all of it except the drafting, which stays for a deliberate
8450
+ // re-enable, matching the disconnect and unpublish cascades.
8451
+ if( error.message !== NOT_FOUND ) throw error;
8452
+
8453
+ const failed = await sendFeedback({
8454
+ adminToken,
8455
+ connection,
8456
+ messages : [ UNAVAILABLE_FEEDBACK ],
8457
+ productId : providerId,
8458
+ shopify,
8459
+ state : 'REQUIRES_ACTION'
8460
+ });
8461
+
8462
+ return {
8463
+ failed : failed ? [ failed.message ] : undefined,
8464
+ message : 'Storefront cannot see this product — deactivated.',
8465
+ request,
8466
+ result : { status : 'inactive' },
8467
+ writes : [
8468
+ {
8469
+ collection : 'product',
8470
+ data : { $set : { status : 'inactive' } },
8471
+ operation : 'update',
8472
+ query : { id : row.id }
8473
+ },
8474
+ {
8475
+ collection : 'advertisement',
8476
+ data : { $set : { status : 'drafted' } },
8477
+ multiple : true,
8478
+ operation : 'update',
8479
+ query : { product : row.id }
8480
+ }
8481
+ ]
8482
+ };
8483
+
8484
+ }
8485
+
8486
+ const variants = await applyOutOfStock({
8487
+ domain,
8488
+ shopify,
8489
+ storefrontAccessToken : settings?.storefrontAccessToken,
8490
+ variants : ( product?.variants?.edges || [] ).map( toVariant )
8491
+ });
8492
+
8493
+ // The product is visible to the channel again, so any prior required-action
8494
+ // feedback clears — ResourceFeedback replaces per send.
8495
+ const failed = await sendFeedback({
8496
+ adminToken,
8497
+ connection,
8498
+ messages : [],
8499
+ productId : providerId,
8500
+ shopify,
8501
+ state : 'ACCEPTED'
8502
+ });
8503
+
8504
+ return {
8505
+ // EVERYTHING PAST THE FIRST PAGE, filled in silently. The first page is
8506
+ // written above; a product with thousands of variants becomes N short
8507
+ // sequential jobs rather than one long loop that risks a BullMQ stall.
8508
+ enqueues : [ {
8509
+ data : { operation : 'variants', product : row.id },
8510
+ name : 'sync',
8511
+ options : { jobId : 'product.shopify.variants.' + row.id + '.' + Date.now() },
8512
+ queue : 'product.shopify.variants'
8513
+ } ],
8514
+ failed : failed ? [ failed.message ] : undefined,
8515
+ message : 'Synced ' + ( product.title || 'product' ) + '.',
8516
+ request,
8517
+ result : { status : 'active', variants : variants.length },
8518
+ writes : [ {
8519
+ collection : 'product',
8520
+ data : {
8521
+ $set : {
8522
+ description : product.description || null,
8523
+ image : product.featuredImage?.url || null,
8524
+ // A successful sync PROVES channel visibility, which reverses the
8525
+ // deactivation above. Ads stay drafted.
8526
+ status : 'active',
8527
+ title : product.title || null,
8528
+ type : product.productType || null,
8529
+ url : ( domain && product.handle ) ? 'https://' + domain + '/products/' + product.handle : null,
8530
+ variants
8531
+ }
8532
+ },
8533
+ operation : 'update',
8534
+ query : { id : row.id }
8535
+ } ]
8536
+ };
8537
+
8538
+ };
8539
+
8540
+ // ONE SHOPIFY PAGE PER RUN, self-chaining on the cursor.
8541
+ //
8542
+ // The first page (no cursor) $sets the array, clearing anything stale from a
8543
+ // prior sync; later pages $push, and Shopify's cursor pagination guarantees no
8544
+ // overlap within a session, so no dedup is needed. A restart begins again at
8545
+ // page one with $set, so a partial-state window is always recoverable.
8546
+ const syncVariants = async ({ connection, context, read, settings, shopify }) => {
8547
+
8548
+ const row = await resolveProduct({ connection, context, read });
8549
+
8550
+ if( ! row ) return { message : 'No product row to paginate.', request : { product : context?.product || null }, skipped : true };
8551
+
8552
+ const cursor = context?.cursor || null;
8553
+ const request = { cursor, product : row.id };
8554
+
8555
+ let page;
8556
+
8557
+ try {
8558
+
8559
+ page = await shopify.storefront.getProductVariantsPage({
8560
+ cursor,
8561
+ domain : connection.shop,
8562
+ productId : row.provider?.id,
8563
+ storefrontAccessToken : settings?.storefrontAccessToken
8564
+ });
8565
+
8566
+ } catch ( error ){
8567
+
8568
+ // Unpublished or deleted mid-pagination, the same contract getProduct
8569
+ // carries. The sync branch's next run owns the feedback; just stop.
8570
+ if( error.message !== NOT_FOUND ) throw error;
8571
+
8572
+ return { message : 'Storefront lost this product mid-pagination — stopping.', request, skipped : true };
8573
+
8574
+ }
8575
+
8576
+ const variants = await applyOutOfStock({
8577
+ domain : connection.shop,
8578
+ shopify,
8579
+ storefrontAccessToken : settings?.storefrontAccessToken,
8580
+ variants : ( page.edges || [] ).map( toVariant )
8581
+ });
8582
+
8583
+ return {
8584
+ ...( page.pageInfo?.hasNextPage && { enqueues : [ {
8585
+ data : { cursor : page.pageInfo.endCursor, operation : 'variants', product : row.id },
8586
+ name : 'page',
8587
+ options : { jobId : 'product.shopify.variants.' + row.id + '.' + Date.now() },
8588
+ queue : 'product.shopify.variants'
8589
+ } ] }),
8590
+ message : variants.length + ' variant(s) on this page.',
8591
+ request,
8592
+ result : { hasNextPage : Boolean( page.pageInfo?.hasNextPage ), variants : variants.length },
8593
+ writes : [ {
8594
+ collection : 'product',
8595
+ data : cursor
8596
+ ? { $push : { variants : { $each : variants } } }
8597
+ : { $set : { variants } },
8598
+ operation : 'update',
8599
+ query : { id : row.id }
8600
+ } ]
8601
+ };
8602
+
8603
+ };
8604
+
8605
+ // Shopify — installed from the App Store, never connected with keys.
8606
+ var shopify = {
8607
+
8608
+ // THE WORDS ON THE BUTTONS. `listing` was `content.redirect.title`, which put
8609
+ // a BUTTON LABEL inside the copy object next to a url — the title and the
8610
+ // address it belongs to sat at different levels and could drift apart.
8611
+ //
8612
+ // It names where the link GOES rather than what it does: installing happens on
8613
+ // the App Store listing, and the dashboard must never imply a store can be
8614
+ // linked from inside it.
8615
+ actions : {
8616
+ create : null,
8617
+ listing : 'View on the Shopify App Store',
8618
+ manage : null,
8619
+ update : null
8620
+ },
8621
+ // INSTALL, not oauth. The grant is an OAuth grant, but the merchant never
8622
+ // sees a consent screen we sent them to -- they start at the App Store, and
8623
+ // the install completes inside Shopify admin without redirecting back. A
8624
+ // Connect button here would be lying about where connecting happens.
8625
+ auth : {
8626
+
8627
+ // ONE STORE PER ORGANIZATION. A Shopify install IS a store, and a second
8628
+ // one would give an organization two catalogues, two order streams and two
8629
+ // usage meters with nothing to say which is authoritative.
8630
+ //
8631
+ // DECLARED AND ENFORCED BY NOTHING, measured 2026-09-13: no repo reads this
8632
+ // key, and the connection collection's { organization, slug } index is not
8633
+ // unique. So a second Shopify connection would be accepted today. The gate
8634
+ // belongs at the connect route with a unique partial index behind it, and
8635
+ // that is api work — this is the declaration it will read.
8636
+ exclusive : true,
8637
+ generated : false,
8638
+
8639
+ // THE MERCHANT STARTS AT SHOPIFY, not here. They install the app from
8640
+ // Shopify's own admin or app store and the grant is exchanged inside that
8641
+ // flow — there is no consent screen we sent them to, which is the whole
8642
+ // reason this is not `oauth`. A Connect button would be lying.
8643
+ //
8644
+ // Both members are install-only, which is exactly why they could not live at
8645
+ // the top of `auth`.
8646
+ install : {
8647
+
8648
+ // SHOPIFY APP PRICING. Was the top-level `events` key, which it never
8649
+ // was: `events.order` is not an event vocabulary, it is a usage meter
8650
+ // handle plus the capped plan a merchant approves. It reads correctly
8651
+ // beside the install it belongs to, and it is the only manifest that
8652
+ // has one.
8653
+ billing : {
8654
+ handle : ORDER_EVENT_HANDLE,
8655
+ // THE SUBSCRIPTION THE APP MINTS FOR ITSELF (Billing API, not the
8656
+ // managed-pricing meter system): a $0 recurring line plus this usage
8657
+ // line, approved by the org owner at Shopify's confirmation screen.
8658
+ // `terms` is the merchant-visible consent copy; "up to" because the
8659
+ // actual rate is plan-derived (conversionRate) and must never exceed
8660
+ // what was approved. cappedAmount bounds one 30-day cycle's usage
8661
+ // charges — a record beyond it errors until the merchant raises it.
8662
+ // ponytail: $1,000 cap ≈ $66k attributed revenue/cycle at 1.5%; raise
8663
+ // via a re-approved subscription when a merchant outgrows it.
8664
+ pricing : {
8665
+ cappedAmount : 1000,
8666
+ currency : 'usd',
8667
+ name : 'Drawbridge Orders',
8668
+ terms : 'Up to 1.5% of revenue from orders attributed to Drawbridge campaigns'
8669
+ }
7758
8670
  },
7759
8671
 
7760
- // WHERE TO GO, which is the only reason this is worth overriding. The
7761
- // generic sentence says a connection is missing permissions and to
7762
- // reconnect it; for an install there is no reconnect button here, and
7763
- // telling a merchant to press one they do not have is worse than saying
7764
- // nothing. This says the place.
7765
- //
7766
- // A STRING, not a keyed map like `connect`: there is one way a grant is
7767
- // too narrow. The shape allows either because the keys under an area
7768
- // belong to the vendor.
7769
- scopes : 'Shopify permissions are out of date. Open the Drawbridge app in your Shopify admin to approve the updated permissions.'
7770
- },
7771
- excerpt : 'Connect your Shopify store to feature products in your campaigns and track conversions.',
7772
- guide : [
7773
- 'Open the Drawbridge listing on the Shopify App Store.',
7774
- 'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
7775
- 'Approve the Drawbridge plan when prompted — during install, or from the connection page here. The connection shows Pending until you do, then Active.',
7776
- 'Come back here — the connections list updates on its own once the install finishes.'
7777
- ],
7778
- },
7779
- // VENDOR-FACING EVENT IDENTITY, published so readers outside this package
7780
- // (drawbridge-sync's usage queue, an operator checking the Dev Dashboard)
7781
- // see the same handle the order hook mints into transactions and enqueues.
7782
- feature : 'organization:connection:shopify',
7783
- fields : [
7784
- {
7785
- // `shop` on the connection record wins when present — it is written by
7786
- // the install, while settings.domain is the stored copy.
7787
- from : 'shop',
7788
- key : 'domain',
7789
- label : 'Store domain'
7790
- }
7791
- ],
7792
- // Was `category : 'commerce'` AND `group : 'ecommerce'` — two words for one
7793
- // fact, which left nobody able to say which one a page read.
7794
- group : 'commerce',
7795
- // verify and event lean entirely on the shared HMAC helper Shopify's scheme
7796
- // is exactly the shape it covers, so there is nothing vendor-specific to
7797
- // write for either. receive is the one hook that genuinely differs by
7798
- // channel: /events buffers whatever arrives with the shop domain stamped on;
7799
- // /compliance enforces the topic allowlist above, because answering one late
7800
- // is a legal deadline rather than a retry.
7801
- hooks : {
8672
+ // ONE GRANT BACKS EVERY ORGANIZATION'S CONNECTION TO ONE STORE, and today
8673
+ // that is carried by a magic `settings.ref === 'shop'` pointer plus
8674
+ // hardcoded slug checks in drawbridge-sync. Declared here so it stops being
8675
+ // magic, and so the token path can be told what it is dealing with: a
8676
+ // shared credential has many writers by definition, refreshAdminToken
8677
+ // already does compare-and-swap on the encrypted blob, and utils' generic
8678
+ // save is a plain $set that would clobber a concurrent rotation.
8679
+ //
8680
+ // NOTHING READS IT YET. The pointer is still the mechanism until the
8681
+ // embedded routes become install hooks and the `shop` collection is renamed
8682
+ // — this is the declaration those will read, put here now because the block
8683
+ // it belongs in exists now.
8684
+ shared : {
8685
+
8686
+ // WHERE THE ONE CREDENTIAL LIVES. Every organization's connection to the
8687
+ // same store points at the same row.
8688
+ collection : 'shop',
8689
+
8690
+ // The settings key holding the lookup value, which is also the field it
8691
+ // matches on that row `connection.settings.shop` finds `shop.shop`.
8692
+ key : 'shop',
8693
+
8694
+ // AND HOW A CONNECTION SAYS ITS CREDENTIAL IS SHARED: settings[ marker ]
8695
+ // equals the collection name. Today that is the magic
8696
+ // `settings.ref === 'shop'` pointer, written at
8697
+ // api/route/shopify-embedded.js and read by hardcoded slug checks in
8698
+ // three repos. Declared, it stops being magic.
8699
+ marker : 'ref'
8700
+
8701
+ }
8702
+
8703
+ },
8704
+ keys : false,
8705
+ none : false,
8706
+
8707
+ // Shopify's install grant is an OAuth exchange under the hood and is NOT
8708
+ // declared here: it runs inside the app flow rather than through the shared
8709
+ // runner, so a complete auth.oauth block would describe a flow this
8710
+ // connection never takes. That disagreement is what the old enum could not
8711
+ // prevent `type : 'install'` beside a stale auth.oauth was legal.
8712
+ oauth : false
8713
+ },
8714
+ // EVERYTHING A MERCHANT READS.
8715
+ //
8716
+ // `errors` is in here rather than at the top level, and that is not a
8717
+ // preference: the connection DOCUMENT carries its own `errors` array of
8718
+ // scope-drift entries, and the document is spread OVER the resolved manifest
8719
+ // downstream — a top-level one would be replaced by that array and never
8720
+ // render.
8721
+ //
8722
+ // `connect` no longer exists as a container. Its other member was `redirect`,
8723
+ // which is a URL and now sits with the vendor's other addresses.
8724
+ content : {
8725
+ confirm : 'Disconnecting deactivates all products from this store, drafts any advertisements that use them, disables Shopify steps in your workflows until you reconnect, and stops revenue tracking for this organization.',
8726
+ description : [
8727
+ 'Installing the Drawbridge app from the Shopify App Store links your store to a single Drawbridge organization and makes your product catalog available inside Drawbridge, so you can feature products in your campaigns and advertisements.',
8728
+ 'Drawbridge attributes orders that originate from your campaigns — matched through cart parameters and lead-mapped discount codes — so you can see the revenue each campaign drives.',
8729
+ 'Drawbridge reads your products, records orders placed through your campaigns, and can issue discount codes. Order and product updates reach Drawbridge through the app\'s own webhooks, which Shopify applies when the app is installed.'
8730
+ ],
8731
+ errors : {
8732
+ connect : {
8733
+ conflict : 'This store is already connected to another Drawbridge organization.',
8734
+ currency : 'This store settles in a currency we can\'t bill yet. Connect a store with a supported settlement currency.',
8735
+ invalid : 'We couldn\'t verify the install. Please try connecting again from the Shopify App Store.'
8736
+ },
8737
+
8738
+ // WHERE TO GO, which is the only reason this is worth overriding. The
8739
+ // generic sentence says a connection is missing permissions and to
8740
+ // reconnect it; for an install there is no reconnect button here, and
8741
+ // telling a merchant to press one they do not have is worse than saying
8742
+ // nothing. This says the place.
8743
+ //
8744
+ // A STRING, not a keyed map like `connect`: there is one way a grant is
8745
+ // too narrow. The shape allows either because the keys under an area
8746
+ // belong to the vendor.
8747
+ scopes : 'Shopify permissions are out of date. Open the Drawbridge app in your Shopify admin to approve the updated permissions.'
8748
+ },
8749
+ excerpt : 'Connect your Shopify store to feature products in your campaigns and track conversions.',
8750
+ guide : [
8751
+ 'Open the Drawbridge listing on the Shopify App Store.',
8752
+ 'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
8753
+ 'Approve the Drawbridge plan when prompted — during install, or from the connection page here. The connection shows Pending until you do, then Active.',
8754
+ 'Come back here — the connections list updates on its own once the install finishes.'
8755
+ ],
8756
+ },
8757
+ // VENDOR-FACING EVENT IDENTITY, published so readers outside this package
8758
+ // (drawbridge-sync's usage queue, an operator checking the Dev Dashboard)
8759
+ // see the same handle the order hook mints into transactions and enqueues.
8760
+ feature : 'organization:connection:shopify',
8761
+ fields : [
8762
+ {
8763
+ // `shop` on the connection record wins when present — it is written by
8764
+ // the install, while settings.domain is the stored copy.
8765
+ from : 'shop',
8766
+ key : 'domain',
8767
+ label : 'Store domain'
8768
+ }
8769
+ ],
8770
+ // Was `category : 'commerce'` AND `group : 'ecommerce'` — two words for one
8771
+ // fact, which left nobody able to say which one a page read.
8772
+ group : 'commerce',
8773
+ // verify and event lean entirely on the shared HMAC helper — Shopify's scheme
8774
+ // is exactly the shape it covers, so there is nothing vendor-specific to
8775
+ // write for either. receive is the one hook that genuinely differs by
8776
+ // channel: /events buffers whatever arrives with the shop domain stamped on;
8777
+ // /compliance enforces the topic allowlist above, because answering one late
8778
+ // is a legal deadline rather than a retry.
8779
+ hooks : {
8780
+
8781
+ auth : {
8782
+ // The install completes inside Shopify admin; the api's callback stores
8783
+ // what it hands back. Scope drift is its own hook because a token can be
8784
+ // perfectly valid while the grant is too narrow.
8785
+ //
8786
+ // connect and disconnect are FALSE rather than `{}`: there is nothing to
8787
+ // call on either side. The install already hands the callback everything
8788
+ // it stores, and a Shopify grant is withdrawn by UNINSTALLING the app in
8789
+ // Shopify admin — which Drawbridge learns about from the app_uninstalled
8790
+ // webhook rather than by asking. `{}` claimed a body implemented
8791
+ // elsewhere; none exists, and none could.
8792
+ connect : false,
8793
+ disconnect : false,
8794
+
8795
+ // WHETHER THE GRANT IS STILL WIDE ENOUGH. A token can be perfectly valid
8796
+ // and still too narrow — a deploy that adds a scope leaves every existing
8797
+ // install short of it, and no webhook fires to say so.
8798
+ //
8799
+ // The comparison is the vendor's, so it belongs here. Reading WHICH
8800
+ // scopes a store granted is not: that lives in the `shop` collection and
8801
+ // needs a controller, which is precisely what a hook in a published
8802
+ // package must not be handed. The caller reads the grant and passes the
8803
+ // string; this answers what is missing from it.
8804
+ //
8805
+ // `shopify` is injected for the same reason it is everywhere else — this
8806
+ // client is imported here and defaulted, never wired in by a caller.
8807
+ scopes : ( { scope }, { shopify = client } = {} ) => ({ result : { missing : scope ? shopify.oauth.missingScopes( scope ) : null } }),
8808
+ // Shopify's install grant is exchanged inside its own app flow, not
8809
+ // through the shared OAuth runner.
8810
+ token : false
8811
+ },
8812
+ // THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
8813
+ // enqueues and events for the shell to perform — see contract.js — and
8814
+ // everything it needs arrives as an argument: `read` (the controller's
8815
+ // read methods, nothing that writes), `shopify` (the SDK, injected because
8816
+ // this package cannot import what depends on it), `adminToken` (minted by
8817
+ // the shell, which persists rotations), `mintId` (so one described write
8818
+ // can reference another), `dispatch` (the caller's own coordinator table,
8819
+ // for the hooks that are dispatches).
8820
+ commerce : {
8821
+
8822
+ // THE CHECKOUT KIT CART. Six operations that were six direct calls into
8823
+ // the storefront client from api/route/subdomain.js.
8824
+ //
8825
+ // IT WRITES NOTHING, which is exactly why it was the last surface to
8826
+ // become a hook — a hook is usually reached to have its effects performed
8827
+ // and this one has none. That reasoning was wrong: a hook is also the one
8828
+ // place a vendor's client is allowed to be called from, and leaving the
8829
+ // cart out meant a public route importing a vendor SDK directly.
8830
+ //
8831
+ // ONE HOOK, OPERATION IN THE CONTEXT, the same shape `inbound.process`
8832
+ // uses for its topic table. Six slots would be six names in the closed
8833
+ // vocabulary for one question — what does this shopper's cart look like
8834
+ // now — asked six ways.
8835
+ //
8836
+ // A USER ERROR IS A 400 AND THE SHOPPER'S OWN WORDS. Shopify's cart
8837
+ // mutations answer "that variant is sold out" as a userError rather than a
8838
+ // transport failure, and the route it replaced already told the two apart.
8839
+ // runHook carries `status` off a thrown error, so it survives to the
8840
+ // caller; the rest of a thrown error's shape does not.
8841
+ cart : async ( { clientId, clientSecret, context, settings }, { fetcher, shopify = client } = {} ) => {
8842
+
8843
+ const { cartId, lineIds, lines, operation } = context || {};
8844
+ const { domain, storefrontAccessToken } = settings || {};
8845
+
8846
+ // MINTED FROM DRAWBRIDGE'S OWN APP CREDENTIALS rather than the store's
8847
+ // token — the Checkout Kit authenticates as the app. The only operation
8848
+ // here that does not touch the merchant's storefront.
8849
+ if( operation === 'checkout' ){
8850
+
8851
+ const token = await shopify.storefront.createCheckoutToken({ clientId, clientSecret, fetcher });
8852
+
8853
+ return { message : 'Checkout token minted.', result : { token } };
8854
+
8855
+ }
8856
+
8857
+ const storefront = { domain, fetcher, storefrontAccessToken };
8858
+
8859
+ try {
8860
+
8861
+ const cart = await ({
8862
+ create : () => shopify.storefront.cartCreate({ ...storefront, lines }),
8863
+ get : () => shopify.storefront.cartGet({ ...storefront, cartId }),
8864
+ linesAdd : () => shopify.storefront.cartLinesAdd({ ...storefront, cartId, lines }),
8865
+ linesRemove : () => shopify.storefront.cartLinesRemove({ ...storefront, cartId, lineIds }),
8866
+ linesUpdate : () => shopify.storefront.cartLinesUpdate({ ...storefront, cartId, lines })
8867
+ }[ operation ] || ( () => {
8868
+
8869
+ throw Object.assign( new Error( 'Unknown cart operation: ' + operation ), { status : 400 });
8870
+
8871
+ }) )();
8872
+
8873
+ return { message : 'Cart ' + operation + '.', request : { cartId : cartId || null, operation }, result : cart };
8874
+
8875
+ } catch ( error ){
8876
+
8877
+ // The shopper can act on this one; everything else is ours.
8878
+ if( error?.userError ) throw Object.assign( error, { status : 400 });
8879
+
8880
+ throw error;
8881
+
8882
+ }
8883
+
8884
+ },
8885
+
8886
+ // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
8887
+ // one lead — which is what lets an order that redeems it be attributed
8888
+ // back.
8889
+ code : async ( { connection, context, step }, { adminToken, shopify = client } = {} ) => {
8890
+
8891
+ const discount = step.settings?.discount;
8892
+
8893
+ const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
8894
+
8895
+ // KEPT even though nothing below sends the address anywhere. A code
8896
+ // that reaches nobody is a wasted action and a merchant's wasted
8897
+ // allowance: issuing is only half a workflow, and the email step after
8898
+ // it needs somewhere to send the code.
8899
+ if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
8900
+ if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
8901
+ if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
8902
+
8903
+ const adminAccessToken = await adminToken();
8904
+
8905
+ // NOTHING IS SENT TO SHOPIFY TO LINK THE CODE TO THE PERSON, because
8906
+ // there is nothing to send: discountRedeemCodeBulkAdd takes a discount
8907
+ // and a list of codes, and no customer
8908
+ // (shopify.dev/docs/api/admin-graphql/2026-04/mutations/discountRedeemCodeBulkAdd).
8909
+ // The link lives entirely on our side — `lead.shopifyDiscountCode`
8910
+ // below — and attribution happens when the order comes back carrying
8911
+ // the code, matched org-scoped against that field by commerce.order.
8912
+ //
8913
+ // This step used to call getOrCreateCustomer first, on the stated
8914
+ // reasoning that "the customer must exist before a code is mapped to
8915
+ // them". No code was ever mapped to them: the result was assigned and
8916
+ // discarded, createDiscountCode never received it, and redemption
8917
+ // attribution never consulted it. All it did was create a customer in
8918
+ // the merchant's store as a side effect of issuing a discount — which
8919
+ // QA reported as surprising, and was right to. A merchant who wants the
8920
+ // buyer to exist at the store adds the Create customer step, which is
8921
+ // what that step is for.
8922
+ const discountCode = await shopify.admin.createDiscountCode({
8923
+ adminAccessToken,
8924
+ code : 'DB-' + generateDiscountCode(),
8925
+ discountId : discount.id,
8926
+ domain : connection.shop
8927
+ });
8928
+
8929
+ if( ! discountCode ) return { message : 'Shopify did not return a discount code — create failed.', request, response : { skipped : true }, skipped : true };
8930
+
8931
+ return {
8932
+ context : {
8933
+ shopifyDiscountCode : discountCode.code,
8934
+ shopifyDiscountId : String( discountCode.id )
8935
+ },
8936
+ message : 'Discount code created and linked to lead.',
8937
+ request,
8938
+ response : { code : discountCode.code, id : String( discountCode.id ) },
8939
+ // bypassDocumentValidation because these are vendor ids on a
8940
+ // Drawbridge document the schema does not declare — the
8941
+ // canonical-identity work resolves it properly.
8942
+ writes : [ {
8943
+ collection : 'lead',
8944
+ data : {
8945
+ $set : {
8946
+ shopifyDiscountCode : discountCode.code,
8947
+ shopifyDiscountId : String( discountCode.id )
8948
+ }
8949
+ },
8950
+ operation : 'update',
8951
+ options : { bypassDocumentValidation : true },
8952
+ query : { id : context.lead }
8953
+ } ]
8954
+ };
8955
+
8956
+ },
8957
+
8958
+ // CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
8959
+ //
8960
+ // IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
8961
+ // customer at the store is a support ticket: the context may already
8962
+ // carry the id from an earlier step, the lead may already be linked from
8963
+ // an earlier run, and Shopify's own get-or-create settles the rest.
8964
+ customer : async ( { connection, context }, { adminToken, read, shopify = client } = {} ) => {
8965
+
8966
+ const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
8967
+
8968
+ if( ! context?.email ) return { message : 'Lead email is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
8969
+ if( ! context?.lead ) return { message : 'Lead id is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
8970
+
8971
+ // Already known from an earlier step in this run.
8972
+ if( context.shopifyCustomerId ){
8973
+
8974
+ return {
8975
+ context : { shopifyCustomerId : context.shopifyCustomerId },
8976
+ message : 'Trigger data already includes a Shopify customer id — reusing.',
8977
+ request,
8978
+ response : { shopifyCustomerId : context.shopifyCustomerId },
8979
+ // Reusing an id is not a creation, so it does not bill.
8980
+ skipped : true
8981
+ };
8982
+
8983
+ }
8984
+
8985
+ const lead = await read.get({ collection : 'lead', query : { id : context.lead } });
8986
+
8987
+ // Already linked by an earlier run.
8988
+ if( lead?.shopifyCustomerId ){
8989
+
8990
+ return {
8991
+ context : { shopifyCustomerId : lead.shopifyCustomerId },
8992
+ message : 'Lead already has a Shopify customer id — reusing.',
8993
+ request,
8994
+ response : { shopifyCustomerId : lead.shopifyCustomerId },
8995
+ skipped : true
8996
+ };
8997
+
8998
+ }
8999
+
9000
+ const adminAccessToken = await adminToken();
9001
+
9002
+ // Shopify keeps first and last separately; Drawbridge keeps one name.
9003
+ // Split on the first space and give everything after it to the
9004
+ // surname, which is wrong for some names and is what the vendor's
9005
+ // shape allows.
9006
+ const parts = ( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
9007
+
9008
+ const customer = await shopify.admin.getOrCreateCustomer({
9009
+ adminAccessToken,
9010
+ domain : connection.shop,
9011
+ email : context.email,
9012
+ firstName : parts.length ? parts[ 0 ] : null,
9013
+ lastName : parts.length > 1 ? parts.slice( 1 ).join( ' ' ) : null,
9014
+ source : 'drawbridge'
9015
+ });
9016
+
9017
+ if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
9018
+
9019
+ return {
9020
+ context : { shopifyCustomerId : customer.id },
9021
+ message : 'Shopify customer created/linked to lead.',
9022
+ request,
9023
+ response : { shopifyCustomerId : customer.id },
9024
+ // The hook's own result, described beside the call that produced it.
9025
+ writes : [ {
9026
+ collection : 'lead',
9027
+ data : { $set : { shopifyCustomerId : customer.id } },
9028
+ operation : 'update',
9029
+ options : { bypassDocumentValidation : true },
9030
+ query : { id : context.lead }
9031
+ } ]
9032
+ };
9033
+
9034
+ },
9035
+
9036
+ // HOW MANY ARE LEFT. Read at the moment a campaign's product list is
9037
+ // rendered rather than stored on the product, because stock is the one
9038
+ // product fact that is stale the instant it is written down.
9039
+ //
9040
+ // SUMMED HERE, not by the caller. Shopify answers per variant and every
9041
+ // caller wanted the total; the one that existed summed it inline, and
9042
+ // summing `Object.values()` over the raw map is how a cache wrapper's own
9043
+ // bookkeeping key got counted as a quantity.
9044
+ inventory : async ( { connection, context }, { adminToken, shopify = client } = {} ) => {
9045
+
9046
+ const productId = context?.product;
9047
+
9048
+ const request = { product : productId || null, shop : connection.shop };
9049
+
9050
+ if( ! productId ) return { message : 'No product id — nothing to count.', request, result : { total : null }, skipped : true };
9051
+
9052
+ const adminAccessToken = await adminToken();
9053
+
9054
+ const byVariant = await shopify.admin.getProductInventory({
9055
+ adminAccessToken,
9056
+ domain : connection.shop,
9057
+ productId
9058
+ });
9059
+
9060
+ return {
9061
+ message : 'Inventory read.',
9062
+ request,
9063
+ result : { total : Object.values( byVariant || {} ).reduce( ( sum, quantity ) => sum + quantity, 0 ) }
9064
+ };
9065
+
9066
+ },
9067
+
9068
+ // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
9069
+ // attribution genuinely is: an order can reach Drawbridge two ways and
9070
+ // they bill differently.
9071
+ //
9072
+ // CONVERSION — a `_drwbrdg_ca` line-item property, injected at
9073
+ // add-to-cart. Causal: the campaign produced the sale, so
9074
+ // it carries a fee.
9075
+ // REDEMPTION — a DB- discount code matched to a lead. Associative: we
9076
+ // cannot claim we caused the purchase, so it is fee-free.
9077
+ //
9078
+ // Both can be true, and an order already recorded as a conversion can
9079
+ // later have a redemption backfilled onto it — `backfill` below.
9080
+ //
9081
+ // IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
9082
+ // order race, the loser's transaction hits a duplicate key, the step
9083
+ // fails and BullMQ redelivers — and the re-run's read at the top finds
9084
+ // what the winner wrote and skips instead of double-billing a merchant
9085
+ // for one purchase. The hook used to loop for this itself; describing
9086
+ // the writes moved the retry to the queue, with the same guarantee.
9087
+ order : async ( { connection, context }, { conversionRate, logger, mintId, read } = {} ) => {
9088
+
9089
+ const {
9090
+ advertisement,
9091
+ created_at : createdAt,
9092
+ currency,
9093
+ customer : orderCustomer,
9094
+ email,
9095
+ id : orderId,
9096
+ line_items : lineItems = [],
9097
+ organization,
9098
+ phone
9099
+ } = context || {};
9100
+
9101
+ const request = { orderId : orderId ? String( orderId ) : null, organization };
9102
+
9103
+ const [ existingOrder, existingRedemption ] = await Promise.all([
9104
+ read.get({ collection : 'order', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } }),
9105
+ read.get({ collection : 'redemption', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } })
9106
+ ]);
9107
+
9108
+ // Already fully recorded. This is the branch the redelivery exists to
9109
+ // reach.
9110
+ if( existingRedemption ){
9111
+
9112
+ return {
9113
+ message : 'Order/redemption already recorded — skipping duplicate.',
9114
+ request,
9115
+ response : {
9116
+ existingOrderId : existingOrder?.id || null,
9117
+ existingRedemptionId : existingRedemption.id,
9118
+ skipped : true
9119
+ },
9120
+ skipped : true
9121
+ };
9122
+
9123
+ }
9124
+
9125
+ const backfill = ! ! existingOrder;
9126
+
9127
+ // ONLY `_drwbrdg_ca`-TAGGED LINES COUNT toward attributed gross.
9128
+ const { attrMap, attributedGross, attributedLines } = attributeLineItems( lineItems );
9129
+
9130
+ const campaign = attrMap[ '_drwbrdg_ca' ] || null;
9131
+
9132
+ const discountCodes = Array.isArray( context?.discount_codes ) ? context.discount_codes : [];
9133
+ const codes = [ ...new Set( discountCodes.map( ( dc ) => dc?.code ).filter( Boolean ) ) ];
9134
+
9135
+ const matchedLeads = codes.length
9136
+ ? await read.aggregate({
9137
+ collection : 'lead',
9138
+ pipeline : [ { $match : { organization, shopifyDiscountCode : { $in : codes } } } ]
9139
+ })
9140
+ : [];
9141
+
9142
+ const codeToLead = {};
9143
+
9144
+ for( const found of matchedLeads ){
9145
+
9146
+ if( found.shopifyDiscountCode ) codeToLead[ found.shopifyDiscountCode ] = found;
9147
+
9148
+ }
9149
+
9150
+ const matchedDiscounts = discountCodes
9151
+ .filter( ( dc ) => dc?.code && codeToLead[ dc.code ] )
9152
+ .map( ( dc ) => ({
9153
+ amount : parseFloat( dc.amount ) || 0,
9154
+ code : dc.code,
9155
+ id : codeToLead[ dc.code ].shopifyDiscountId || null
9156
+ }) );
9157
+
9158
+ const matchedLead = matchedDiscounts.length ? codeToLead[ matchedDiscounts[ 0 ].code ] : null;
9159
+
9160
+ const discount = matchedDiscounts.length
9161
+ ? {
9162
+ amount : matchedDiscounts.reduce( ( sum, entry ) => sum + entry.amount, 0 ),
9163
+ codes : matchedDiscounts
9164
+ }
9165
+ : null;
9166
+
9167
+ const matchedCodes = new Set( matchedDiscounts.map( ( entry ) => entry.code ) );
9168
+
9169
+ // A DB- code we minted that matched no lead. Logged rather than
9170
+ // ignored: it means a code went out and its lead link was lost, which
9171
+ // is revenue we cannot attribute and nobody would otherwise notice.
9172
+ const unmatched = codes.filter( ( code ) => code.startsWith( 'DB-' ) && ! matchedCodes.has( code ) );
9173
+
9174
+ if( unmatched.length ){
9175
+
9176
+ logger?.warn?.( 'shopify.order.discount.unmatched', {
9177
+ campaign : campaign || null,
9178
+ codes : JSON.stringify( unmatched ),
9179
+ isConversion : ! ! campaign,
9180
+ orderId : String( orderId ),
9181
+ organization
9182
+ });
9183
+
9184
+ }
9185
+
9186
+ if( ( ! campaign && ! discount ) || ( backfill && ! discount ) ){
9187
+
9188
+ return {
9189
+ message : backfill
9190
+ ? 'Order already recorded and no Drawbridge discount code matched — nothing to backfill.'
9191
+ : 'Order has no Drawbridge attribution — not recording.',
9192
+ request,
9193
+ response : { skipped : true },
9194
+ skipped : true
9195
+ };
9196
+
9197
+ }
9198
+
9199
+ let advertisementId = null;
9200
+ let affiliateId = null;
9201
+ let campaignOrganization = organization;
9202
+ let gross = 0;
9203
+ let leadId = null;
9204
+ let lines = [];
9205
+ let orderCampaign = null;
9206
+ let pageId = null;
9207
+
9208
+ const isConversion = ! ! campaign;
9209
+
9210
+ const customerPhone = toE164( orderCustomer?.phone || phone ) || null;
9211
+
9212
+ const matchPhones = [ ...new Set([
9213
+ customerPhone,
9214
+ toE164( context?.billing_address?.phone ),
9215
+ toE164( context?.shipping_address?.phone )
9216
+ ].filter( Boolean ) ) ];
9217
+
9218
+ if( isConversion ){
9219
+
9220
+ const campaignDoc = await read.get({ collection : 'campaign', query : { id : campaign } });
9221
+
9222
+ // THE CAMPAIGN MUST BELONG TO THE DELIVERING SHOP'S OWN ORG.
9223
+ //
9224
+ // `_drwbrdg_ca` is a line-item property, and on most Shopify themes
9225
+ // a buyer can attach arbitrary line-item properties via cart
9226
+ // permalinks or the AJAX cart API — and campaign ids are
9227
+ // discoverable from public campaign links. Without this check, a $1
9228
+ // order on ANY connected store carrying another org's campaign id
9229
+ // records a conversion under that org: its revenue totals climb,
9230
+ // its usage document is incremented, and its matching leads gain
9231
+ // order counts — a cross-tenant write driven entirely by the buyer.
9232
+ //
9233
+ // A stale or garbage id lands here too, so this is also the null
9234
+ // check: either way the order simply has no Drawbridge attribution.
9235
+ if( ! campaignDoc || campaignDoc.organization !== organization ){
9236
+
9237
+ return {
9238
+ message : 'Order carried a campaign attribution that does not belong to this store — not recording.',
9239
+ request,
9240
+ response : { skipped : true },
9241
+ skipped : true
9242
+ };
9243
+
9244
+ }
9245
+
9246
+ advertisementId = attrMap[ '_drwbrdg_ad' ] || advertisement || null;
9247
+ affiliateId = attrMap[ '_drwbrdg_af' ] || null;
9248
+ campaignOrganization = campaignDoc.organization;
9249
+ gross = attributedGross;
9250
+ lines = attributedLines;
9251
+ orderCampaign = campaign;
9252
+ pageId = attrMap[ '_drwbrdg_pg' ] || null;
9253
+
9254
+ // MATCHED ON EVERY IDENTITY WE HOLD, canonical forms included,
9255
+ // because the address on an order is often not the one they entered
9256
+ // with.
9257
+ const identifiers = [];
9258
+
9259
+ const canonicalEmail = toCanonicalEmail( email );
9260
+
9261
+ if( email ) identifiers.push({ email : email.toLowerCase() });
9262
+ if( canonicalEmail ) identifiers.push({ 'canonical.email.value' : canonicalEmail });
9263
+ if( matchPhones.length ) identifiers.push({ 'phone.number' : { $in : matchPhones } });
9264
+ if( matchPhones.length ) identifiers.push({ 'canonical.phone.value' : { $in : matchPhones } });
9265
+
9266
+ if( identifiers.length ){
9267
+
9268
+ // Narrowed to the campaign first; an org-wide match is the
9269
+ // fallback, because a buyer who entered a different campaign is
9270
+ // still the same person and still worth linking.
9271
+ const lead = await read.get({
9272
+ collection : 'lead',
9273
+ query : {
9274
+ campaigns : { $in : [ campaign ] },
9275
+ organization : campaignOrganization,
9276
+ $or : identifiers
9277
+ }
9278
+ });
9279
+
9280
+ leadId = lead?.id || null;
9281
+
9282
+ if( ! leadId ){
9283
+
9284
+ const orgLead = await read.get({
9285
+ collection : 'lead',
9286
+ query : { organization : campaignOrganization, $or : identifiers }
9287
+ });
9288
+
9289
+ leadId = orgLead?.id || null;
9290
+
9291
+ }
9292
+
9293
+ }
9294
+
9295
+ } else {
9296
+
9297
+ // REDEMPTION. The lead is known from the code, and the whole order
9298
+ // counts as gross — there are no tagged lines to narrow it to.
9299
+ leadId = matchedLead.id;
9300
+ orderCampaign = ( matchedLead.campaigns || [] ).length === 1 ? matchedLead.campaigns[ 0 ] : null;
9301
+ gross = lineItems.reduce( ( sum, item ) => {
9302
+
9303
+ const line = toLine( item );
9304
+
9305
+ return sum + ( line.price * line.quantity );
9306
+
9307
+ }, 0 );
9308
+ lines = lineItems.map( toLine );
9309
+
9310
+ }
9311
+
9312
+ const org = await read.get({ collection : 'organization', query : { id : campaignOrganization } });
9313
+
9314
+ let rate = 0;
9315
+
9316
+ // THE FEE IS THE CONVERSION FEE, and only a conversion carries one. A
9317
+ // redemption is associative — we cannot claim we caused the purchase —
9318
+ // so it is recorded fee-free.
9319
+ //
9320
+ // `conversionRate` IS INJECTED rather than imported, and that is not
9321
+ // ceremony. This file used to `import { conversionRate } from
9322
+ // '../../plans.js'`, which made a VENDOR MANIFEST depend on
9323
+ // DRAWBRIDGE'S PLAN CATALOGUE — backwards, and a genuine import cycle
9324
+ // (features -> connections -> shopify -> plans -> features) that no
9325
+ // derived feature list could be added around. What a merchant is
9326
+ // charged is Drawbridge's business, not Shopify's; the manifest reads
9327
+ // the subscription and asks.
9328
+ if( isConversion ){
9329
+
9330
+ const subscription = await read.get({ collection : 'subscription', query : { id : org?.subscription } });
9331
+
9332
+ rate = conversionRate( subscription );
9333
+
9334
+ }
9335
+
9336
+ const fee = isConversion ? Math.round( gross * rate ) / 100 : 0;
9337
+ const net = Math.round( ( gross - fee ) * 100 ) / 100;
9338
+
9339
+ const currencyCode = ( currency || 'usd' ).toLowerCase();
9340
+ const purchasedAt = new Date( createdAt || Date.now() );
9341
+
9342
+ const customer = ( orderCustomer || email || phone )
9343
+ ? {
9344
+ acceptsMarketing : orderCustomer?.email_marketing_consent?.state
9345
+ ? orderCustomer.email_marketing_consent.state === 'subscribed'
9346
+ : ( typeof orderCustomer?.accepts_marketing === 'boolean' ? orderCustomer.accepts_marketing : null ),
9347
+ email : orderCustomer?.email || email || null,
9348
+ firstName : orderCustomer?.first_name || null,
9349
+ id : orderCustomer?.id ? String( orderCustomer.id ) : null,
9350
+ lastName : orderCustomer?.last_name || null,
9351
+ phone : customerPhone
9352
+ }
9353
+ : null;
9354
+
9355
+ const source = connection?.source
9356
+ ? { domain : connection.source.domain, id : connection.source.id }
9357
+ : undefined;
9358
+
9359
+ // EVERY PURCHASE GETS AN ORDER DOCUMENT, whichever way it reached us.
9360
+ //
9361
+ // A redemption used to write only a redemption row, which meant the
9362
+ // money existed on the Redemptions page and nowhere else: contact
9363
+ // totals are summed from the ORDER collection, so a real purchase by a
9364
+ // known entrant contributed nothing to their revenue and was invisible
9365
+ // to every revenue segment. `type` keeps the two kinds apart for the
9366
+ // figures that must stay causal (the fee, the Revenue page's
9367
+ // conversion column) without splitting the source of truth in two.
9368
+ const createsOrder = ! backfill && ( isConversion || Boolean( discount ) );
9369
+
9370
+ // MINTED HERE, because the redemption names its order and the usage
9371
+ // job names both — a description cannot read a write's result, so the
9372
+ // id exists before either does. Minted for the redemption too, so the
9373
+ // order can name it back.
9374
+ // MINTED AS A PAIR. `mintId` hands back both halves — the driver's `_id` and
9375
+ // its string `id` — because this package has no mongodb dependency and
9376
+ // cannot construct one. The string is what every reference below uses; the
9377
+ // `_id` goes on the create so the document is born with the pair the
9378
+ // controller would otherwise derive.
9379
+ const mintedOrder = ! existingOrder?.id && createsOrder ? mintId() : null;
9380
+
9381
+ const orderDocId = existingOrder?.id || mintedOrder?.id || null;
9382
+ const mintedRedemption = discount ? mintId() : null;
9383
+
9384
+ const redemptionDocId = mintedRedemption?.id || null;
9385
+
9386
+ const writes = [];
9387
+
9388
+ if( createsOrder ){
9389
+
9390
+ writes.push({
9391
+ collection : 'order',
9392
+ data : {
9393
+ advertisement : advertisementId,
9394
+ affiliate : affiliateId,
9395
+ campaign : orderCampaign,
9396
+ currency : currencyCode,
9397
+ customer,
9398
+ discount,
9399
+ fee,
9400
+ gross,
9401
+ _id : mintedOrder?._id,
9402
+ id : orderDocId,
9403
+ lead : leadId,
9404
+ lines,
9405
+ net,
9406
+ organization : campaignOrganization,
9407
+ page : pageId,
9408
+ provider : { id : String( orderId ), slug : 'shopify' },
9409
+ purchasedAt,
9410
+ rate,
9411
+ // Null on a conversion that matched no code of ours; the
9412
+ // backfill branch below sets it when one arrives later.
9413
+ redemption : redemptionDocId,
9414
+ source,
9415
+ status : 'completed',
9416
+ type : isConversion ? 'conversion' : 'redemption'
9417
+ },
9418
+ operation : 'create'
9419
+ });
9420
+
9421
+ if( org?.usage ){
9422
+
9423
+ writes.push({
9424
+ collection : 'usage',
9425
+ // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
9426
+ // conversion revenue and is the figure the fee is charged
9427
+ // against, so redemption money gets its own key rather than
9428
+ // changing what an existing number means.
9429
+ data : { $inc : isConversion
9430
+ ? { 'totals.revenue' : gross }
9431
+ : { 'totals.redemptionRevenue' : gross }
9432
+ },
9433
+ operation : 'update',
9434
+ query : { id : org.usage }
9435
+ });
9436
+
9437
+ }
9438
+
9439
+ if( leadId ){
9440
+
9441
+ writes.push({
9442
+ collection : 'lead',
9443
+ // Same grouped shape the contact carries, so a lead and the
9444
+ // contact built from it cannot be read two different ways.
9445
+ data : { $inc : {
9446
+ 'totals.orders.total' : 1,
9447
+ ...( isConversion
9448
+ ? { 'totals.orders.conversions' : 1 }
9449
+ : { 'totals.orders.redemptions' : 1 }
9450
+ )
9451
+ } },
9452
+ operation : 'update',
9453
+ options : { bypassDocumentValidation : true },
9454
+ query : { id : leadId }
9455
+ });
9456
+
9457
+ }
9458
+
9459
+ }
9460
+
9461
+ if( discount ){
9462
+
9463
+ writes.push({
9464
+ collection : 'redemption',
9465
+ data : {
9466
+ advertisement : advertisementId,
9467
+ affiliate : affiliateId,
9468
+ campaign : orderCampaign,
9469
+ code : matchedDiscounts[ 0 ]?.code || null,
9470
+ currency : currencyCode,
9471
+ customer,
9472
+ discount,
9473
+ gross,
9474
+ _id : mintedRedemption?._id,
9475
+ id : redemptionDocId,
9476
+ lead : leadId,
9477
+ order : orderDocId,
9478
+ organization : campaignOrganization,
9479
+ page : pageId,
9480
+ provider : { id : String( orderId ), slug : 'shopify' },
9481
+ purchasedAt,
9482
+ source,
9483
+ status : 'completed'
9484
+ },
9485
+ operation : 'create'
9486
+ });
9487
+
9488
+ if( org?.usage ){
9489
+
9490
+ writes.push({
9491
+ collection : 'usage',
9492
+ data : { $inc : { 'totals.redemptions' : 1 } },
9493
+ operation : 'update',
9494
+ query : { id : org.usage }
9495
+ });
9496
+
9497
+ }
9498
+
9499
+ if( leadId ){
9500
+
9501
+ writes.push({
9502
+ collection : 'lead',
9503
+ data : { $inc : { 'totals.redemptions' : 1 } },
9504
+ operation : 'update',
9505
+ options : { bypassDocumentValidation : true },
9506
+ query : { id : leadId }
9507
+ });
9508
+
9509
+ }
9510
+
9511
+ // THE BACKFILL LEG: a conversion recorded earlier, matched to one of
9512
+ // our codes now. The order already exists, so nothing above created
9513
+ // it and nothing has told it which redemption it belongs to — this
9514
+ // is the only write that closes that link. Skipped when the order
9515
+ // was created in this same run, because it was minted carrying the
9516
+ // id already.
9517
+ if( backfill && orderDocId && redemptionDocId ){
9518
+
9519
+ writes.push({
9520
+ collection : 'order',
9521
+ data : { $set : { redemption : redemptionDocId } },
9522
+ operation : 'update',
9523
+ query : { id : orderDocId }
9524
+ });
9525
+
9526
+ }
9527
+
9528
+ }
9529
+
9530
+ // SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
9531
+ // id so a redelivery cannot charge twice. Never on a backfill: the fee
9532
+ // was charged when the order was first recorded. Enqueues run after
9533
+ // the transaction commits, so the job can never observe rows that
9534
+ // roll back.
9535
+ const billable = org?.billingProvider === 'shopify' && fee > 0 && ! backfill;
9536
+
9537
+ // THE EFFECTIVE METER HANDLE. The provider row's orderEventHandle
9538
+ // (admin-editable, for the day the plan config's meter changes)
9539
+ // overrides the built-in default; slugified again on read as belt
9540
+ // and braces with the save-side formatter, since a drifted stored
9541
+ // value would mint transactions no meter classifies. Read directly
9542
+ // (not via providerSettings — that module imports this one) and
9543
+ // only for a billable order, which keeps the cost off every
9544
+ // ordinary redemption.
9545
+ const providerRow = billable
9546
+ ? await read.get({ collection : 'provider', query : { slug : 'shopify' } })
9547
+ : null;
9548
+
9549
+ const { orderEventHandle } = providerRow?.settings ? decrypt( providerRow.settings ) : {};
9550
+
9551
+ const handle = ( typeof orderEventHandle === 'string' && slugify( orderEventHandle ) ) || ORDER_EVENT_HANDLE;
9552
+
9553
+ // A billable order with no shop id is revenue lost SILENTLY — the
9554
+ // gate below just yields no job, the order records normally, and the
9555
+ // connection looks healthy. An Error (not a warn) because this is
9556
+ // money: the order id goes in the MESSAGE, since Sentry matches
9557
+ // titles, and the health check's source self-heal is what makes the
9558
+ // state rare enough to page on.
9559
+ if( billable && ! connection?.source?.id ){
9560
+
9561
+ logger?.error?.( new Error(
9562
+ 'shopify.usage.billing.skipped on order ' + orderId + ': '
9563
+ + Math.round( fee * 100 ) + ' cents not billed — connection '
9564
+ + connection?.id + ' has no source.id'
9565
+ ), {
9566
+ extra : {
9567
+ connectionId : connection?.id,
9568
+ fee,
9569
+ orderDocId,
9570
+ orderId : String( orderId )
9571
+ }
9572
+ });
9573
+
9574
+ }
7802
9575
 
7803
- auth : {
7804
- // The install completes inside Shopify admin; the api's callback stores
7805
- // what it hands back. Scope drift is its own hook because a token can be
7806
- // perfectly valid while the grant is too narrow.
7807
- //
7808
- // connect and disconnect are FALSE rather than `{}`: there is nothing to
7809
- // call on either side. The install already hands the callback everything
7810
- // it stores, and a Shopify grant is withdrawn by UNINSTALLING the app in
7811
- // Shopify admin which Drawbridge learns about from the app_uninstalled
7812
- // webhook rather than by asking. `{}` claimed a body implemented
7813
- // elsewhere; none exists, and none could.
7814
- connect : false,
7815
- disconnect : false,
9576
+ const enqueues = ( billable && connection?.source?.id )
9577
+ ? [ {
9578
+ data : {
9579
+ // The meter handle travels WITH the job so the sender can
9580
+ // verify it against the transaction prefix and refuse a
9581
+ // drifted pair idempotency keys are permanent, so a
9582
+ // mistraceable event can never be resent under its own key.
9583
+ handle,
9584
+ idempotencyKey : String( orderId ),
9585
+ orderDocId,
9586
+ orderId : String( orderId ),
9587
+ rate,
9588
+ shopId : connection.source.id,
9589
+ // The App Events API returns no event id, so one is generated
9590
+ // here — the event handle plus the order id — and sent as the
9591
+ // event's `reference`. queue/usage.js stamps the same id onto
9592
+ // the order as billed.transaction.
9593
+ transaction : handle + '.' + orderId,
9594
+ value : Math.round( fee * 100 )
9595
+ },
9596
+ name : 'billing',
9597
+ options : { jobId : 'shopify.usage.' + orderId },
9598
+ queue : 'usage'
9599
+ } ]
9600
+ : [];
7816
9601
 
7817
- // WHETHER THE GRANT IS STILL WIDE ENOUGH. A token can be perfectly valid
7818
- // and still too narrow — a deploy that adds a scope leaves every existing
7819
- // install short of it, and no webhook fires to say so.
9602
+ return {
9603
+ enqueues,
9604
+ message : backfill
9605
+ ? 'Redemption backfilled for an already-recorded order.'
9606
+ : isConversion ? 'Order recorded.' : 'Discount redemption recorded (fee-free).',
9607
+ request,
9608
+ response : {
9609
+ campaign : orderCampaign,
9610
+ currency : currencyCode,
9611
+ discount,
9612
+ fee,
9613
+ gross,
9614
+ lead : leadId,
9615
+ lines : lines.length,
9616
+ net,
9617
+ orderId : String( orderId )
9618
+ },
9619
+ // ONE TRANSACTION. The order, the redemption and both totals
9620
+ // counters land together or not at all — a half-written attribution
9621
+ // is revenue counted twice or not at all, and neither is
9622
+ // recoverable by hand.
9623
+ transaction : writes.length > 0,
9624
+ writes
9625
+ };
9626
+
9627
+ },
9628
+
9629
+ // A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
9630
+ // the product pipeline; the actual field sync happens there.
7820
9631
  //
7821
- // The comparison is the vendor's, so it belongs here. Reading WHICH
7822
- // scopes a store granted is not: that lives in the `shop` collection and
7823
- // needs a controller, which is precisely what a hook in a published
7824
- // package must not be handed. The caller reads the grant and passes the
7825
- // string; this answers what is missing from it.
9632
+ // The shell has already refused a missing or inactive Shopify connection,
9633
+ // so what is left is the two things only this hook can know are wrong.
9634
+ // A PRODUCT, IN THREE MOVES. Nested by operation rather than by three
9635
+ // slots in the closed vocabulary, the same shape inbound.process uses
9636
+ // for its topic table they are one subject, and a vendor that syncs
9637
+ // products has to answer all three or none.
7826
9638
  //
7827
- // `shopify` is injected for the same reason it is everywhere else — this
7828
- // package cannot import @drawbridge/shopify, which depends on it.
7829
- scopes : ( { scope }, { shopify } = {} ) => ({ result : { missing : scope ? shopify.oauth.missingScopes( scope ) : null } }),
7830
- // Shopify's install grant is exchanged inside its own app flow, not
7831
- // through the shared OAuth runner.
7832
- token : false
7833
- },
7834
- // THE VENDOR'S OWN WORK, here in full. Every body describes its writes,
7835
- // enqueues and events for the shell to perform — see contract.js — and
7836
- // everything it needs arrives as an argument: `read` (the controller's
7837
- // read methods, nothing that writes), `shopify` (the SDK, injected because
7838
- // this package cannot import what depends on it), `adminToken` (minted by
7839
- // the shell, which persists rotations), `mintId` (so one described write
7840
- // can reference another), `dispatch` (the caller's own coordinator table,
7841
- // for the hooks that are dispatches).
7842
- commerce : {
9639
+ // record a webhook said a product changed: upsert the row and
9640
+ // queue the pull
9641
+ // sync pull the product from the storefront and write what it
9642
+ // says, including what it says by NOT answering
9643
+ // variants one page of variants, self-chaining
9644
+ //
9645
+ // `sync` and `variants` were 443 lines of drawbridge-sync's
9646
+ // queue/product.js a shadow implementation of the step this manifest
9647
+ // already declared, importing three of the vendor's client modules from
9648
+ // a worker file.
9649
+ product : async ( { connection, context, settings, workflow }, { adminToken, mintId, read, shopify = client } = {} ) => {
7843
9650
 
7844
- // NOT YET. The Checkout Kit cart path is `api/route/subdomain.js`, six direct
7845
- // calls into `@drawbridge/shopify/storefront` that write nothing — which is
7846
- // exactly why it never became a hook: a hook is reached to have its effects
7847
- // performed, and this one has none to perform. It moves here when the vendor
7848
- // client moves into the manifest and `subdomain.js` stops importing the
7849
- // package directly.
7850
- cart : false,
9651
+ const operation = context?.operation || 'record';
7851
9652
 
7852
- // MINT A DISCOUNT CODE against the merchant's chosen discount, mapped to
7853
- // one lead — which is what lets an order that redeems it be attributed
7854
- // back.
7855
- code : async ( { connection, context, step }, { adminToken, shopify } = {} ) => {
9653
+ if( operation === 'sync' ) return syncProduct({ adminToken, connection, context, read, settings, shopify });
7856
9654
 
7857
- const discount = step.settings?.discount;
9655
+ if( operation === 'variants' ) return syncVariants({ connection, context, read, settings, shopify });
7858
9656
 
7859
- const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
7860
9657
 
7861
- // KEPT even though nothing below sends the address anywhere. A code
7862
- // that reaches nobody is a wasted action and a merchant's wasted
7863
- // allowance: issuing is only half a workflow, and the email step after
7864
- // it needs somewhere to send the code.
7865
- if( ! context?.email ) return { message : 'Lead email is missing.', request, response : { skipped : true }, skipped : true };
7866
- if( ! context?.lead ) return { message : 'Lead id is missing.', request, response : { skipped : true }, skipped : true };
7867
- if( ! discount?.id ) return { message : 'Discount is not configured on this step.', request, response : { skipped : true }, skipped : true };
9658
+ const request = {
9659
+ numericId : context?.id || null,
9660
+ organizationId : workflow?.organization || null,
9661
+ title : context?.title || null
9662
+ };
7868
9663
 
7869
- const adminAccessToken = await adminToken();
9664
+ if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
7870
9665
 
7871
- // NOTHING IS SENT TO SHOPIFY TO LINK THE CODE TO THE PERSON, because
7872
- // there is nothing to send: discountRedeemCodeBulkAdd takes a discount
7873
- // and a list of codes, and no customer
7874
- // (shopify.dev/docs/api/admin-graphql/2026-04/mutations/discountRedeemCodeBulkAdd).
7875
- // The link lives entirely on our side — `lead.shopifyDiscountCode`
7876
- // below — and attribution happens when the order comes back carrying
7877
- // the code, matched org-scoped against that field by commerce.order.
9666
+ // The shop domain is half the identity below. Without it the upsert
9667
+ // would match on provider id alone and could collide across stores.
9668
+ if( ! connection.shop ) return { message : 'Skipped — Shopify connection is missing shop domain.', request, response : { skipped : true }, skipped : true };
9669
+
9670
+ const providerId = 'gid://shopify/Product/' + context.id;
9671
+
9672
+ // THE ROW'S OWN ID WHEN IT ALREADY EXISTS, a fresh one only when it
9673
+ // does not.
7878
9674
  //
7879
- // This step used to call getOrCreateCustomer first, on the stated
7880
- // reasoning that "the customer must exist before a code is mapped to
7881
- // them". No code was ever mapped to them: the result was assigned and
7882
- // discarded, createDiscountCode never received it, and redemption
7883
- // attribution never consulted it. All it did was create a customer in
7884
- // the merchant's store as a side effect of issuing a discount which
7885
- // QA reported as surprising, and was right to. A merchant who wants the
7886
- // buyer to exist at the store adds the Create customer step, which is
7887
- // what that step is for.
7888
- const discountCode = await shopify.admin.createDiscountCode({
7889
- adminAccessToken,
7890
- code : 'DB-' + generateDiscountCode(),
7891
- discountId : discount.id,
7892
- domain : connection.shop
7893
- });
9675
+ // The write below is an upsert whose `id` sits in $setOnInsert, so a
9676
+ // product we have seen before keeps the id it was first given and a
9677
+ // newly minted one is silently discarded. Minting unconditionally
9678
+ // therefore published an id belonging to no row: the enqueue named a
9679
+ // product the worker then had to fall back off, and the step's
9680
+ // response showed QA a value that matched nothing they could look up
9681
+ // which is exactly how it was reported.
9682
+ //
9683
+ // Reading first costs one indexed lookup on the same key the upsert
9684
+ // matches. A concurrent redelivery can still have both runs miss and
9685
+ // mint, which is why the job carries the PROVIDER identity too and the
9686
+ // worker falls back to provider + shop — stable either way.
9687
+ const query = {
9688
+ 'provider.id' : providerId,
9689
+ 'provider.slug' : 'shopify',
9690
+ 'source.domain' : connection.shop
9691
+ };
7894
9692
 
7895
- if( ! discountCode ) return { message : 'Shopify did not return a discount code — create failed.', request, response : { skipped : true }, skipped : true };
9693
+ const existing = read?.get ? await read.get({ collection : 'product', query }) : null;
9694
+
9695
+ const mintedProduct = existing?.id ? null : mintId();
9696
+
9697
+ const productId = existing?.id || mintedProduct.id;
7896
9698
 
7897
9699
  return {
7898
- context : {
7899
- shopifyDiscountCode : discountCode.code,
7900
- shopifyDiscountId : String( discountCode.id )
7901
- },
7902
- message : 'Discount code created and linked to lead.',
9700
+ enqueues : [ {
9701
+ data : { product : productId, providerId, shop : connection.shop },
9702
+ name : 'workflow',
9703
+ // The numeric id, not the gid: BullMQ refuses a custom id with a
9704
+ // colon in it, and every gid carries two.
9705
+ options : { jobId : 'product.workflow.shopify.' + context.id + '.' + Date.now() },
9706
+ queue : 'product.shopify'
9707
+ } ],
9708
+ message : 'Product sync queued from Shopify webhook.',
7903
9709
  request,
7904
- response : { code : discountCode.code, id : String( discountCode.id ) },
7905
- // bypassDocumentValidation because these are vendor ids on a
7906
- // Drawbridge document the schema does not declare — the
7907
- // canonical-identity work resolves it properly.
9710
+ response : { productId, providerId, title : context?.title || null },
9711
+ // KEYED ON PROVIDER + SHOP, so the same product in two stores stays
9712
+ // two rows. `connections` accumulates rather than replaces: one
9713
+ // store can be linked to several organizations, and each keeps its
9714
+ // own claim on the row.
7908
9715
  writes : [ {
7909
- collection : 'lead',
9716
+ collection : 'product',
7910
9717
  data : {
7911
- $set : {
7912
- shopifyDiscountCode : discountCode.code,
7913
- shopifyDiscountId : String( discountCode.id )
9718
+ $addToSet : { connections : connection.id },
9719
+ $setOnInsert : {
9720
+ ...( mintedProduct && { _id : mintedProduct._id }),
9721
+ id : productId,
9722
+ provider : { id : providerId, slug : 'shopify' },
9723
+ 'source.id' : connection.id,
9724
+ status : 'active'
7914
9725
  }
7915
9726
  },
7916
9727
  operation : 'update',
7917
- options : { bypassDocumentValidation : true },
7918
- query : { id : context.lead }
9728
+ options : { upsert : true },
9729
+ query
7919
9730
  } ]
7920
9731
  };
7921
9732
 
7922
- },
9733
+ }
9734
+
9735
+ },
9736
+ contacts : { remove : false, sync : false },
9737
+
9738
+ // THE EMBEDDED-APP SURFACE. Only a vendor whose merchants arrive from its
9739
+ // own app store has one, and these thirteen slots are the thirteen routes
9740
+ // that used to live in api/route/shopify-embedded.js.
9741
+ //
9742
+ // A HOOK DESCRIBES ITS WRITES and the shell performs them, so none of these
9743
+ // holds a controller — `read` is the controller's read methods and nothing
9744
+ // else. Where one REFUSES it throws with a status, and the route answers
9745
+ // what it said. The helpers they share are above the manifest.
9746
+ install : {
9747
+
9748
+ account : {
9749
+
9750
+ // ESTABLISH THE DURABLE MERCHANT-TO-STORE LINK. The breakout
9751
+ // authenticates the merchant once, top-level, and stashes their user
9752
+ // token in `install.token.save`; App Bridge has already proven shop
9753
+ // control by the time this runs.
9754
+ //
9755
+ // THE STASHED TOKEN IS THE TRUST ANCHOR, not merely any valid user
9756
+ // token: the presented one must BE this shop's one-shot handoff token,
9757
+ // unexpired, and an access or pat record — a refresh or oauth-state
9758
+ // token that happens to resolve to a user must not qualify, which is
9759
+ // the rule a session is held to in drawbridge-api's middleware.
9760
+ link : async ( { context }, { read } = {} ) => {
7923
9761
 
7924
- // CREATE THE BUYER AT THE STORE, so an order can be attributed to them.
7925
- //
7926
- // IDEMPOTENT THREE WAYS, because this runs on every entry and a duplicate
7927
- // customer at the store is a support ticket: the context may already
7928
- // carry the id from an earlier step, the lead may already be linked from
7929
- // an earlier run, and Shopify's own get-or-create settles the rest.
7930
- customer : async ( { connection, context }, { adminToken, read, shopify } = {} ) => {
9762
+ const shop = sanitizeDomain( context?.shop );
9763
+ const token = context?.token;
7931
9764
 
7932
- const request = { email : context?.email || null, lead : context?.lead || null, shop : connection.shop };
9765
+ if( ! shop || ! token ) throw refusal( 'shop and token are required', 400 );
7933
9766
 
7934
- if( ! context?.email ) return { message : 'Lead email is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
7935
- if( ! context?.lead ) return { message : 'Lead id is missing — cannot create Shopify customer.', request, response : { skipped : true }, skipped : true };
9767
+ const now = new Date();
7936
9768
 
7937
- // Already known from an earlier step in this run.
7938
- if( context.shopifyCustomerId ){
9769
+ const record = await read.get({ collection : 'shop', query : { shop } });
9770
+
9771
+ const stashed = record?.linkToken ? decrypt( record.linkToken )?.token : null;
9772
+ const expired = ! record?.linkTokenExpiresAt || new Date( record.linkTokenExpiresAt ) <= now;
9773
+
9774
+ if( ! stashed || expired || stashed !== token ) throw refusal( 'Invalid or expired link token', 401 );
9775
+
9776
+ const granted = await read.get({
9777
+ collection : 'token',
9778
+ query : {
9779
+ revoked : false,
9780
+ tokenHash : hashToken( token ),
9781
+ type : { $in : [ tokenTypes.access, tokenTypes.pat ] }
9782
+ }
9783
+ });
9784
+
9785
+ if( ! granted?.user || ( granted.expiresAt && new Date( granted.expiresAt ) <= now ) ){
9786
+
9787
+ throw refusal( 'Invalid or expired link token', 401 );
9788
+
9789
+ }
9790
+
9791
+ // RE-LINKING TO A DIFFERENT ACCOUNT TEARS THE OLD LINK DOWN. The
9792
+ // store's existing org connections belong to the outgoing user, who
9793
+ // can no longer see or manage them from the embedded app. Left in
9794
+ // place they keep serving that user's campaigns off this store while
9795
+ // the merchant is told they are connected as somebody else. Dependent
9796
+ // cleanup fires from drawbridge-sync's connection change stream, the
9797
+ // single home for that cascade.
9798
+ const switching = Boolean( record?.user ) && record.user !== granted.user;
9799
+
9800
+ const connections = switching
9801
+ ? await read.aggregate({
9802
+ collection : 'connection',
9803
+ pipeline : [
9804
+ { $match : { shop, slug : 'shopify' } },
9805
+ { $project : { _id : 0, id : 1 } }
9806
+ ]
9807
+ })
9808
+ : [];
7939
9809
 
7940
9810
  return {
7941
- context : { shopifyCustomerId : context.shopifyCustomerId },
7942
- message : 'Trigger data already includes a Shopify customer id — reusing.',
7943
- request,
7944
- response : { shopifyCustomerId : context.shopifyCustomerId },
7945
- // Reusing an id is not a creation, so it does not bill.
7946
- skipped : true
9811
+ message : switching ? 'Linked to a different Drawbridge account.' : 'Linked.',
9812
+ request : { shop },
9813
+ result : { connected : true, switched : switching },
9814
+ // ALL OR NONE. A half-done switch is a store linked to the new
9815
+ // account while the old account's connections still serve from it.
9816
+ transaction : true,
9817
+ writes : [
9818
+ ...( connections || [] ).map( ( connection ) => ({
9819
+ collection : 'connection',
9820
+ operation : 'delete',
9821
+ query : { id : connection.id }
9822
+ }) ),
9823
+ {
9824
+ collection : 'shop',
9825
+ data : { $set : { linkToken : null, linkTokenExpiresAt : null, user : granted.user } },
9826
+ operation : 'update',
9827
+ query : { shop }
9828
+ }
9829
+ ]
7947
9830
  };
7948
9831
 
7949
- }
9832
+ },
7950
9833
 
7951
- const lead = await read.get({ collection : 'lead', query : { id : context.lead } });
9834
+ // DISCONNECT THE ACCOUNT FROM THE STORE. Every org connection goes and
9835
+ // the durable link clears; the offline token and the shop record stay,
9836
+ // because the app is still installed and the merchant may link a
9837
+ // different account next.
9838
+ unlink : async ( { context }, { read } = {} ) => {
7952
9839
 
7953
- // Already linked by an earlier run.
7954
- if( lead?.shopifyCustomerId ){
9840
+ const shop = sanitizeDomain( context?.shop );
9841
+
9842
+ if( ! shop ) throw refusal( 'shop is required', 400 );
9843
+
9844
+ const connections = await read.aggregate({
9845
+ collection : 'connection',
9846
+ pipeline : [
9847
+ { $match : { shop, slug : 'shopify' } },
9848
+ { $project : { _id : 0, id : 1 } }
9849
+ ]
9850
+ });
7955
9851
 
7956
9852
  return {
7957
- context : { shopifyCustomerId : lead.shopifyCustomerId },
7958
- message : 'Lead already has a Shopify customer id — reusing.',
7959
- request,
7960
- response : { shopifyCustomerId : lead.shopifyCustomerId },
7961
- skipped : true
9853
+ message : 'Unlinked ' + ( connections || [] ).length + ' connection(s).',
9854
+ request : { shop },
9855
+ result : { ok : true },
9856
+ transaction : true,
9857
+ writes : [
9858
+ ...( connections || [] ).map( ( connection ) => ({
9859
+ collection : 'connection',
9860
+ operation : 'delete',
9861
+ query : { id : connection.id }
9862
+ }) ),
9863
+ {
9864
+ collection : 'shop',
9865
+ data : { $set : { linkToken : null, linkTokenExpiresAt : null, user : null } },
9866
+ operation : 'update',
9867
+ query : { shop }
9868
+ }
9869
+ ]
7962
9870
  };
7963
9871
 
7964
9872
  }
7965
9873
 
7966
- const adminAccessToken = await adminToken();
9874
+ },
7967
9875
 
7968
- // Shopify keeps first and last separately; Drawbridge keeps one name.
7969
- // Split on the first space and give everything after it to the
7970
- // surname, which is wrong for some names and is what the vendor's
7971
- // shape allows.
7972
- const parts = ( lead?.name || '' ).trim().split( /\s+/ ).filter( Boolean );
9876
+ organizations : {
7973
9877
 
7974
- const customer = await shopify.admin.getOrCreateCustomer({
7975
- adminAccessToken,
7976
- domain : connection.shop,
7977
- email : context.email,
7978
- firstName : parts.length ? parts[ 0 ] : null,
7979
- lastName : parts.length > 1 ? parts.slice( 1 ).join( ' ' ) : null,
7980
- source : 'drawbridge'
7981
- });
9878
+ // BIND ONE ORGANIZATION TO THIS STORE. The longest slot here, and every
9879
+ // gate in it came from a line in the route it replaces.
9880
+ add : async ( { context }, { currencies, mintId, read, shopify = client } = {} ) => {
7982
9881
 
7983
- if( ! customer?.id ) return { message : 'Shopify did not return a customer id — create/lookup failed.', request, response : { skipped : true }, skipped : true };
9882
+ const organization = context?.organization;
9883
+ const shop = sanitizeDomain( context?.shop );
7984
9884
 
7985
- return {
7986
- context : { shopifyCustomerId : customer.id },
7987
- message : 'Shopify customer created/linked to lead.',
7988
- request,
7989
- response : { shopifyCustomerId : customer.id },
7990
- // The hook's own result, described beside the call that produced it.
7991
- writes : [ {
7992
- collection : 'lead',
7993
- data : { $set : { shopifyCustomerId : customer.id } },
7994
- operation : 'update',
7995
- options : { bypassDocumentValidation : true },
7996
- query : { id : context.lead }
7997
- } ]
7998
- };
9885
+ if( ! shop ) throw refusal( 'shop is required', 400 );
9886
+ if( ! organization ) throw refusal( 'organization is required', 400 );
7999
9887
 
8000
- },
9888
+ const record = await read.get({ collection : 'shop', query : { shop } });
8001
9889
 
8002
- // AN ORDER ARRIVED AT THE STORE. The largest hook in the family, because
8003
- // attribution genuinely is: an order can reach Drawbridge two ways and
8004
- // they bill differently.
8005
- //
8006
- // CONVERSION — a `_drwbrdg_ca` line-item property, injected at
8007
- // add-to-cart. Causal: the campaign produced the sale, so
8008
- // it carries a fee.
8009
- // REDEMPTION — a DB- discount code matched to a lead. Associative: we
8010
- // cannot claim we caused the purchase, so it is fee-free.
8011
- //
8012
- // Both can be true, and an order already recorded as a conversion can
8013
- // later have a redemption backfilled onto it — `backfill` below.
8014
- //
8015
- // IDEMPOTENT THROUGH THE RETRY, one layer up: two deliveries of the same
8016
- // order race, the loser's transaction hits a duplicate key, the step
8017
- // fails and BullMQ redelivers — and the re-run's read at the top finds
8018
- // what the winner wrote and skips instead of double-billing a merchant
8019
- // for one purchase. The hook used to loop for this itself; describing
8020
- // the writes moved the retry to the queue, with the same guarantee.
8021
- order : async ( { connection, context }, { conversionRate, logger, mintId, read } = {} ) => {
9890
+ if( ! record?.user ) throw refusal( 'Account is not connected for this store', 409 );
8022
9891
 
8023
- const {
8024
- advertisement,
8025
- created_at : createdAt,
8026
- currency,
8027
- customer : orderCustomer,
8028
- email,
8029
- id : orderId,
8030
- line_items : lineItems = [],
8031
- organization,
8032
- phone
8033
- } = context || {};
9892
+ // The linked merchant may only add orgs they own or hold the
9893
+ // connections capability on.
9894
+ const owned = await merchantOrganizations({ read, user : record.user });
9895
+ const org = ( owned || [] ).find( ( entry ) => entry.id === organization );
8034
9896
 
8035
- const request = { orderId : orderId ? String( orderId ) : null, organization };
9897
+ if( ! org ) throw refusal( 'No access to this organization', 403 );
8036
9898
 
8037
- const [ existingOrder, existingRedemption ] = await Promise.all([
8038
- read.get({ collection : 'order', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } }),
8039
- read.get({ collection : 'redemption', query : { 'provider.id' : String( orderId ), 'provider.slug' : 'shopify' } })
8040
- ]);
9899
+ // The dashboard's connection-write gate, applied to the embedded path
9900
+ // too: a locked, suspended, past-due, unpaid or paused org cannot take
9901
+ // a new revenue connection either way in.
9902
+ if( ! CONNECTABLE_STATUSES.includes( org.billingStatus ) ){
8041
9903
 
8042
- // Already fully recorded. This is the branch the redelivery exists to
8043
- // reach.
8044
- if( existingRedemption ){
9904
+ throw refusal( 'This organization can\'t accept connections right now — check its billing status in the dashboard', 403 );
8045
9905
 
8046
- return {
8047
- message : 'Order/redemption already recorded — skipping duplicate.',
8048
- request,
8049
- response : {
8050
- existingOrderId : existingOrder?.id || null,
8051
- existingRedemptionId : existingRedemption.id,
8052
- skipped : true
8053
- },
8054
- skipped : true
8055
- };
9906
+ }
8056
9907
 
8057
- }
9908
+ // ONE STORE PER ORGANIZATION. The write below keys on
9909
+ // { organization, slug }, so without this a second store would
9910
+ // silently re-point the org's existing connection at itself.
9911
+ // Re-adding from the SAME store stays allowed. A connection carrying
9912
+ // no `shop` is a legacy dashboard link and counts as another store.
9913
+ if( org.connection && org.connection.shop !== shop ){
8058
9914
 
8059
- const backfill = ! ! existingOrder;
9915
+ throw refusal( 'This organization is already connected to another Shopify store', 409 );
8060
9916
 
8061
- // ONLY `_drwbrdg_ca`-TAGGED LINES COUNT toward attributed gross.
8062
- const { attrMap, attributedGross, attributedLines } = attributeLineItems( lineItems );
9917
+ }
8063
9918
 
8064
- const campaign = attrMap[ '_drwbrdg_ca' ] || null;
9919
+ const settings = record?.settings ? decrypt( record.settings ) : null;
8065
9920
 
8066
- const discountCodes = Array.isArray( context?.discount_codes ) ? context.discount_codes : [];
8067
- const codes = [ ...new Set( discountCodes.map( ( dc ) => dc?.code ).filter( Boolean ) ) ];
9921
+ if( ! settings?.accessToken ){
8068
9922
 
8069
- const matchedLeads = codes.length
8070
- ? await read.aggregate({
8071
- collection : 'lead',
8072
- pipeline : [ { $match : { organization, shopifyDiscountCode : { $in : codes } } } ]
8073
- })
8074
- : [];
9923
+ throw refusal( 'Store is not installed — open the app in the Shopify admin first', 409 );
8075
9924
 
8076
- const codeToLead = {};
9925
+ }
8077
9926
 
8078
- for( const found of matchedLeads ){
9927
+ const plan = await storePlan({ record, shopify });
8079
9928
 
8080
- if( found.shopifyDiscountCode ) codeToLead[ found.shopifyDiscountCode ] = found;
9929
+ if( plan.unavailable ) throw refusal( 'Couldn\'t verify the store\'s plan — try again shortly', 502 );
8081
9930
 
8082
- }
9931
+ if( ! plan.active ) throw refusal( 'Approve a plan before connecting organizations', 402 );
8083
9932
 
8084
- const matchedDiscounts = discountCodes
8085
- .filter( ( dc ) => dc?.code && codeToLead[ dc.code ] )
8086
- .map( ( dc ) => ({
8087
- amount : parseFloat( dc.amount ) || 0,
8088
- code : dc.code,
8089
- id : codeToLead[ dc.code ].shopifyDiscountId || null
8090
- }) );
9933
+ // THE STOREFRONT TOKEN IS MINTED ONCE PER STORE. Product sync and
9934
+ // storefront reads need it and token exchange only yields the admin
9935
+ // token. It lives in its own field because a session refresh
9936
+ // overwrites `settings` wholesale. BEST-EFFORT: a miss must not block
9937
+ // the link, and the next add re-mints it.
9938
+ let storefront = null;
8091
9939
 
8092
- const matchedLead = matchedDiscounts.length ? codeToLead[ matchedDiscounts[ 0 ].code ] : null;
9940
+ if( ! record.storefront ){
9941
+
9942
+ try {
9943
+
9944
+ const minted = await shopify.oauth.createStorefrontToken({
9945
+ adminAccessToken : settings.accessToken,
9946
+ shop
9947
+ });
9948
+
9949
+ if( minted ) storefront = encrypt({ token : minted });
9950
+
9951
+ } catch ( _ ) {
9952
+
9953
+ // re-minted on a later add
9954
+
9955
+ }
8093
9956
 
8094
- const discount = matchedDiscounts.length
8095
- ? {
8096
- amount : matchedDiscounts.reduce( ( sum, entry ) => sum + entry.amount, 0 ),
8097
- codes : matchedDiscounts
8098
9957
  }
8099
- : null;
8100
9958
 
8101
- const matchedCodes = new Set( matchedDiscounts.map( ( entry ) => entry.code ) );
9959
+ // LIVE SHOP METADATA. `source.id` is required for App Pricing
9960
+ // commission billing. Best-effort again — a fetch miss links with
9961
+ // whatever the shop record already held.
9962
+ let currency = record?.currency || null;
9963
+ let source = record?.source || null;
8102
9964
 
8103
- // A DB- code we minted that matched no lead. Logged rather than
8104
- // ignored: it means a code went out and its lead link was lost, which
8105
- // is revenue we cannot attribute and nobody would otherwise notice.
8106
- const unmatched = codes.filter( ( code ) => code.startsWith( 'DB-' ) && ! matchedCodes.has( code ) );
9965
+ try {
8107
9966
 
8108
- if( unmatched.length ){
9967
+ const shopData = await shopify.oauth.getShop({ adminAccessToken : settings.accessToken, shop });
8109
9968
 
8110
- logger?.warn?.( 'shopify.order.discount.unmatched', {
8111
- campaign : campaign || null,
8112
- codes : JSON.stringify( unmatched ),
8113
- isConversion : ! ! campaign,
8114
- orderId : String( orderId ),
8115
- organization
8116
- });
9969
+ if( shopData ){
8117
9970
 
8118
- }
9971
+ currency = String( shopData.currency || '' ).toLowerCase() || null;
9972
+ source = { domain : shopData.myshopifyDomain, id : String( shopData.id ), label : shopData.name };
8119
9973
 
8120
- if( ( ! campaign && ! discount ) || ( backfill && ! discount ) ){
9974
+ }
8121
9975
 
8122
- return {
8123
- message : backfill
8124
- ? 'Order already recorded and no Drawbridge discount code matched — nothing to backfill.'
8125
- : 'Order has no Drawbridge attribution — not recording.',
8126
- request,
8127
- response : { skipped : true },
8128
- skipped : true
8129
- };
9976
+ } catch ( _ ) {
8130
9977
 
8131
- }
9978
+ // keep what the shop record had
8132
9979
 
8133
- let advertisementId = null;
8134
- let affiliateId = null;
8135
- let campaignOrganization = organization;
8136
- let gross = 0;
8137
- let leadId = null;
8138
- let lines = [];
8139
- let orderCampaign = null;
8140
- let pageId = null;
9980
+ }
8141
9981
 
8142
- const isConversion = ! ! campaign;
9982
+ // A STORE SETTLING OUTSIDE THE BILLING-FX SET would fail the
9983
+ // connection validator INSIDE the transaction below, which surfaces
9984
+ // as an opaque 500. Said plainly here instead. `currencies` is
9985
+ // injected rather than imported: which currencies Drawbridge can bill
9986
+ // is not a fact about Shopify.
9987
+ if( currency && currencies && ! currencies.includes( currency ) ){
8143
9988
 
8144
- const customerPhone = toE164( orderCustomer?.phone || phone ) || null;
9989
+ throw refusal( 'This store settles in a currency we can\'t bill yet. Connect a store with a supported settlement currency.', 422 );
8145
9990
 
8146
- const matchPhones = [ ...new Set([
8147
- customerPhone,
8148
- toE164( context?.billing_address?.phone ),
8149
- toE164( context?.shipping_address?.phone )
8150
- ].filter( Boolean ) ) ];
9991
+ }
9992
+
9993
+ // THE CONNECTION'S ID, KNOWN BEFORE THE WRITE THAT CREATES IT,
9994
+ // because the organization's own refs have to name it inside the same
9995
+ // transaction. An existing connection already has one.
9996
+ const minted = org.connection?.id ? null : mintId();
9997
+ const connectionId = org.connection?.id || minted.id;
9998
+
9999
+ return {
10000
+ message : 'Connected ' + org.name + ' to ' + shop + '.',
10001
+ request : { organization, shop },
10002
+ result : { ok : true },
10003
+ transaction : true,
10004
+ writes : [
10005
+ ...( storefront ? [ {
10006
+ collection : 'shop',
10007
+ data : { $set : { storefront } },
10008
+ operation : 'update',
10009
+ query : { shop }
10010
+ } ] : [] ),
10011
+ {
10012
+ collection : 'connection',
10013
+ data : {
10014
+ $set : {
10015
+ currency,
10016
+ errors : [],
10017
+ // The token of record lives on the shared `shop` row; this
10018
+ // is the pointer that satisfies the required `settings`
10019
+ // string, and `auth.install.shared` declares how to read it.
10020
+ settings : encrypt({ ref : 'shop', shop }),
10021
+ shop,
10022
+ status : 'pending',
10023
+ ...( source && { source })
10024
+ },
10025
+ $setOnInsert : {
10026
+ ...( minted && { _id : minted._id, id : minted.id }),
10027
+ feature : 'organization:connection:shopify',
10028
+ organization,
10029
+ slug : 'shopify'
10030
+ }
10031
+ },
10032
+ operation : 'update',
10033
+ options : { upsert : true },
10034
+ query : { organization, slug : 'shopify' }
10035
+ },
10036
+ {
10037
+ collection : 'organization',
10038
+ data : {
10039
+ // $addToSet UNCONDITIONALLY. The route this replaces only
10040
+ // registered the refs when the upsert INSERTED, which meant an
10041
+ // org whose refs had drifted stayed drifted through every
10042
+ // re-link. Adding a member that is already there is a no-op,
10043
+ // so doing it every time costs nothing and repairs the drift.
10044
+ $addToSet : {
10045
+ 'connections.groups' : 'ecommerce',
10046
+ 'connections.ids' : connectionId,
10047
+ 'connections.keys' : 'shopify'
10048
+ },
10049
+ $set : { billingProvider : 'shopify' }
10050
+ },
10051
+ operation : 'update',
10052
+ query : { id : organization }
10053
+ }
10054
+ ]
10055
+ };
10056
+
10057
+ },
10058
+
10059
+ // WHAT THIS STORE CAN SEE: the linked merchant's organizations, the
10060
+ // store's live plan, and the products the merchant has to act on.
10061
+ //
10062
+ // AN ORG CONNECTED TO A DIFFERENT STORE IS OMITTED ENTIRELY, because
10063
+ // `add` would refuse it — a list that offers what the next call rejects
10064
+ // is worse than a shorter one.
10065
+ list : async ( { context }, { conversionRate, planTitle, read, shopify = client } = {} ) => {
10066
+
10067
+ const shop = sanitizeDomain( context?.shop );
8151
10068
 
8152
- if( isConversion ){
10069
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8153
10070
 
8154
- const campaignDoc = await read.get({ collection : 'campaign', query : { id : campaign } });
10071
+ const record = await read.get({ collection : 'shop', query : { shop } });
8155
10072
 
8156
- // THE CAMPAIGN MUST BELONG TO THE DELIVERING SHOP'S OWN ORG.
8157
- //
8158
- // `_drwbrdg_ca` is a line-item property, and on most Shopify themes
8159
- // a buyer can attach arbitrary line-item properties via cart
8160
- // permalinks or the AJAX cart API — and campaign ids are
8161
- // discoverable from public campaign links. Without this check, a $1
8162
- // order on ANY connected store carrying another org's campaign id
8163
- // records a conversion under that org: its revenue totals climb,
8164
- // its usage document is incremented, and its matching leads gain
8165
- // order counts — a cross-tenant write driven entirely by the buyer.
8166
- //
8167
- // A stale or garbage id lands here too, so this is also the null
8168
- // check: either way the order simply has no Drawbridge attribution.
8169
- if( ! campaignDoc || campaignDoc.organization !== organization ){
10073
+ if( ! record?.user ){
8170
10074
 
8171
10075
  return {
8172
- message : 'Order carried a campaign attribution that does not belong to this store — not recording.',
8173
- request,
8174
- response : { skipped : true },
8175
- skipped : true
10076
+ message : 'No account linked to this store.',
10077
+ request : { shop },
10078
+ result : {
10079
+ account : null,
10080
+ connected : false,
10081
+ organizations : [],
10082
+ plan : { active : false, name : null },
10083
+ unavailableProducts : [],
10084
+ unavailableProductsTotal : 0
10085
+ }
8176
10086
  };
8177
10087
 
8178
10088
  }
8179
10089
 
8180
- advertisementId = attrMap[ '_drwbrdg_ad' ] || advertisement || null;
8181
- affiliateId = attrMap[ '_drwbrdg_af' ] || null;
8182
- campaignOrganization = campaignDoc.organization;
8183
- gross = attributedGross;
8184
- lines = attributedLines;
8185
- orderCampaign = campaign;
8186
- pageId = attrMap[ '_drwbrdg_pg' ] || null;
8187
-
8188
- // MATCHED ON EVERY IDENTITY WE HOLD, canonical forms included,
8189
- // because the address on an order is often not the one they entered
8190
- // with.
8191
- const identifiers = [];
10090
+ const owned = await merchantOrganizations({ read, user : record.user });
10091
+
10092
+ const organizations = ( owned || [] )
10093
+ .filter( ( org ) => ! org.connection || org.connection.shop === shop )
10094
+ .map( ( org ) => ({
10095
+ // The org's OWN billing status decides whether `add` accepts it.
10096
+ // Sent up so the app can disable Connect and name the reason
10097
+ // rather than letting the merchant click into a 403 —
10098
+ // listed-but-blocked beats hidden, because an org vanishing from
10099
+ // the list is indistinguishable from a bug.
10100
+ billingStatus : org.billingStatus || null,
10101
+ connectable : CONNECTABLE_STATUSES.includes( org.billingStatus ),
10102
+ connected : Boolean( org.connection ),
10103
+ conversion : conversionRate( org.subscription ),
10104
+ id : org.id,
10105
+ name : org.name,
10106
+ plan : planTitle( org.subscription ),
10107
+ status : org.connection?.status || null
10108
+ }) );
8192
10109
 
8193
- const canonicalEmail = toCanonicalEmail( email );
10110
+ // PRODUCTS THE MERCHANT MUST ACT ON, for the app's publishing section
10111
+ // — App Store 5.7.8 and 5.7.11: a sales channel reports its own
10112
+ // publishing problems rather than pushing them to the product page.
10113
+ // `status : 'inactive'` is exactly the set drawbridge-sync deactivated
10114
+ // because the Storefront API could not see them. Scoped to THIS store,
10115
+ // so one shop never sees another org's titles.
10116
+ const connections = ( owned || [] )
10117
+ .filter( ( org ) => org.connection?.shop === shop && org.connection.id )
10118
+ .map( ( org ) => org.connection.id );
10119
+
10120
+ const [ account, plan, unavailable ] = await Promise.all([
10121
+ read.get({ collection : 'user', query : { id : record.user } }),
10122
+ storePlan({ record, shopify }),
10123
+ connections.length
10124
+ ? read.aggregate({
10125
+ collection : 'product',
10126
+ pipeline : [
10127
+ { $match : { connections : { $in : connections }, 'source.domain' : shop, status : 'inactive' } },
10128
+ // $facet so the app can name the first 25 AND report a
10129
+ // truthful total: listing 25 of 40 under "25 products need
10130
+ // attention" would understate the problem.
10131
+ {
10132
+ $facet : {
10133
+ items : [ { $sort : { title : 1 } }, { $limit : 25 }, { $project : { _id : 0, id : 1, title : 1 } } ],
10134
+ total : [ { $count : 'count' } ]
10135
+ }
10136
+ }
10137
+ ]
10138
+ })
10139
+ : []
10140
+ ]);
8194
10141
 
8195
- if( email ) identifiers.push({ email : email.toLowerCase() });
8196
- if( canonicalEmail ) identifiers.push({ 'canonical.email.value' : canonicalEmail });
8197
- if( matchPhones.length ) identifiers.push({ 'phone.number' : { $in : matchPhones } });
8198
- if( matchPhones.length ) identifiers.push({ 'canonical.phone.value' : { $in : matchPhones } });
10142
+ const facet = ( unavailable || [] )[ 0 ] || {};
8199
10143
 
8200
- if( identifiers.length ){
10144
+ // THE STAMP OVERLAYS THE BLIND PROBE. storePlan can only see line
10145
+ // items, which managed pricing never populates — the truth-based
10146
+ // verdict lives on the connections, written from Partner-ledger
10147
+ // accrual by sync. A charge id is proof; an explicit null is billed
10148
+ // orders with nothing accrued, which is what makes the banner render.
10149
+ if( plan.active && plan.metered !== true ){
8201
10150
 
8202
- // Narrowed to the campaign first; an org-wide match is the
8203
- // fallback, because a buyer who entered a different campaign is
8204
- // still the same person and still worth linking.
8205
- const lead = await read.get({
8206
- collection : 'lead',
8207
- query : {
8208
- campaigns : { $in : [ campaign ] },
8209
- organization : campaignOrganization,
8210
- $or : identifiers
8211
- }
10151
+ const stamped = await read.aggregate({
10152
+ collection : 'connection',
10153
+ pipeline : [
10154
+ { $match : { shop, slug : 'shopify' } },
10155
+ { $project : { _id : 0, 'source.metered' : 1 } }
10156
+ ]
8212
10157
  });
8213
10158
 
8214
- leadId = lead?.id || null;
8215
-
8216
- if( ! leadId ){
10159
+ const verdicts = ( stamped || [] ).map( ( row ) => row?.source?.metered );
8217
10160
 
8218
- const orgLead = await read.get({
8219
- collection : 'lead',
8220
- query : { organization : campaignOrganization, $or : identifiers }
8221
- });
10161
+ if( verdicts.some( ( verdict ) => typeof verdict === 'string' ) ) plan.metered = true;
10162
+ else if( verdicts.some( ( verdict ) => verdict === null ) ) plan.metered = false;
8222
10163
 
8223
- leadId = orgLead?.id || null;
10164
+ }
8224
10165
 
10166
+ return {
10167
+ message : organizations.length + ' organization(s) for this store.',
10168
+ request : { shop },
10169
+ result : {
10170
+ account : { email : account?.email || null },
10171
+ connected : true,
10172
+ organizations,
10173
+ plan,
10174
+ unavailableProducts : ( facet.items || [] ).map( ( product ) => ({
10175
+ id : product.id,
10176
+ title : product.title || 'Untitled product'
10177
+ }) ),
10178
+ // The total can exceed the 25 listed above, and has to.
10179
+ unavailableProductsTotal : ( facet.total || [] )[ 0 ]?.count || 0
8225
10180
  }
10181
+ };
8226
10182
 
8227
- }
10183
+ },
8228
10184
 
8229
- } else {
10185
+ // DISCONNECT ONE ORGANIZATION. The connection is deleted and nothing
10186
+ // else: every dependent — products, ads, steps, workflows, and the org's
10187
+ // own connection refs — is torn down by drawbridge-sync's connection
10188
+ // change stream. The shared token and the other orgs are untouched.
10189
+ remove : async ( { context }, { read } = {} ) => {
8230
10190
 
8231
- // REDEMPTION. The lead is known from the code, and the whole order
8232
- // counts as gross there are no tagged lines to narrow it to.
8233
- leadId = matchedLead.id;
8234
- orderCampaign = ( matchedLead.campaigns || [] ).length === 1 ? matchedLead.campaigns[ 0 ] : null;
8235
- gross = lineItems.reduce( ( sum, item ) => {
10191
+ const organization = context?.organization;
10192
+ const shop = sanitizeDomain( context?.shop );
8236
10193
 
8237
- const line = toLine( item );
10194
+ if( ! shop ) throw refusal( 'shop is required', 400 );
10195
+ if( ! organization ) throw refusal( 'organization is required', 400 );
8238
10196
 
8239
- return sum + ( line.price * line.quantity );
10197
+ const record = await read.get({ collection : 'shop', query : { shop } });
8240
10198
 
8241
- }, 0 );
8242
- lines = lineItems.map( toLine );
10199
+ if( ! record?.user ) throw refusal( 'Account is not connected for this store', 409 );
8243
10200
 
8244
- }
10201
+ // The same gate as `add`: whoever drives the Shopify admin acts as
10202
+ // the linked account, and removes only orgs that account can manage.
10203
+ const owned = await merchantOrganizations({ read, user : record.user });
8245
10204
 
8246
- const org = await read.get({ collection : 'organization', query : { id : campaignOrganization } });
10205
+ if( ! ( owned || [] ).some( ( entry ) => entry.id === organization ) ){
8247
10206
 
8248
- let rate = 0;
10207
+ throw refusal( 'No access to this organization', 403 );
8249
10208
 
8250
- // THE FEE IS THE CONVERSION FEE, and only a conversion carries one. A
8251
- // redemption is associative — we cannot claim we caused the purchase —
8252
- // so it is recorded fee-free.
8253
- //
8254
- // `conversionRate` IS INJECTED rather than imported, and that is not
8255
- // ceremony. This file used to `import { conversionRate } from
8256
- // '../../plans.js'`, which made a VENDOR MANIFEST depend on
8257
- // DRAWBRIDGE'S PLAN CATALOGUE — backwards, and a genuine import cycle
8258
- // (features -> connections -> shopify -> plans -> features) that no
8259
- // derived feature list could be added around. What a merchant is
8260
- // charged is Drawbridge's business, not Shopify's; the manifest reads
8261
- // the subscription and asks.
8262
- if( isConversion ){
10209
+ }
8263
10210
 
8264
- const subscription = await read.get({ collection : 'subscription', query : { id : org?.subscription } });
10211
+ const connection = await read.get({
10212
+ collection : 'connection',
10213
+ query : { organization, shop, slug : 'shopify' }
10214
+ });
8265
10215
 
8266
- rate = conversionRate( subscription );
10216
+ if( ! connection ) throw refusal( 'No connection to remove', 404 );
10217
+
10218
+ return {
10219
+ message : 'Disconnected.',
10220
+ request : { organization, shop },
10221
+ result : { ok : true },
10222
+ writes : [ { collection : 'connection', operation : 'delete', query : { id : connection.id } } ]
10223
+ };
8267
10224
 
8268
10225
  }
8269
10226
 
8270
- const fee = isConversion ? Math.round( gross * rate ) / 100 : 0;
8271
- const net = Math.round( ( gross - fee ) * 100 ) / 100;
10227
+ },
8272
10228
 
8273
- const currencyCode = ( currency || 'usd' ).toLowerCase();
8274
- const purchasedAt = new Date( createdAt || Date.now() );
10229
+ plan : {
10230
+
10231
+ // RELEASE EVERY ACTIVE APPROVAL. The App Home then re-checks the plan
10232
+ // and falls back to "Choose a plan".
10233
+ cancel : ( { context }, { read, shopify = client } = {} ) => releaseApprovals({ context, read, shopify }),
10234
+
10235
+ // ONE ATTRIBUTED ORDER'S USAGE CHARGE. Enqueued per conversion by the
10236
+ // order step and run on a retrying queue, so this may be called
10237
+ // several times for one order — which is safe because the idempotency
10238
+ // key is PERMANENT for billing events and Shopify settles the repeat.
10239
+ //
10240
+ // THE HANDLE IS CHECKED AGAINST THE MANIFEST'S OWN DECLARATION, which
10241
+ // is a real strengthening of what it replaced: drawbridge-sync compared
10242
+ // the job's `handle` against the job's `transaction` — two fields from
10243
+ // the same enqueuer, so a drifted enqueuer agreed with itself. The
10244
+ // handle is the one string that decides whether the event bills at all,
10245
+ // and this is the file that publishes it.
10246
+ //
10247
+ // REFUSE BEFORE SENDING, because sending burns the order's PERMANENT
10248
+ // idempotency key on an event that misclassifies or mistraces. The
10249
+ // throw rides the queue's normal retry path and the abandoned-job alert
10250
+ // pages on exhaustion.
10251
+ //
10252
+ // DELIBERATELY NO TIMESTAMP. sendAppEvent defaults to the SEND time,
10253
+ // which is always inside the merchant's current billing cycle at the
10254
+ // moment of sending and so can never hit PERIOD_CLOSED. Pinning the
10255
+ // purchase time reads more correct and is strictly worse: a retry
10256
+ // backoff crossing a cycle boundary would carry a closed-cycle
10257
+ // timestamp, Shopify would 202-accept, silently drop it, and the
10258
+ // commission would be lost. Send-time's worst case is benign — the
10259
+ // charge lands in the next cycle, but it lands.
10260
+ charge : async ( { clientId, clientSecret, context, manifest }, { shopify = client } = {} ) => {
10261
+
10262
+ const { idempotencyKey, order, orderId, rate, revision, shopId, timestamp, transaction, value } = context || {};
10263
+
10264
+ // THE MANIFEST'S HANDLE, unless an operator overrode it on the
10265
+ // provider row — which is the same precedence the automatic path
10266
+ // mints against, and the recovery migration is the only caller that
10267
+ // can supply one. The GUARD holds either way: whichever handle is
10268
+ // in force, the transaction has to carry it, which is what keeps
10269
+ // the event's classifier and its trace id telling one story.
10270
+ const handle = context?.handle || manifest.auth.install.billing.handle;
10271
+
10272
+ if( ! transaction || ! String( transaction ).startsWith( handle + '.' ) ){
10273
+
10274
+ throw new Error( 'shopify.usage.billing.handle.mismatch on order ' + orderId + ': transaction '
10275
+ + ( transaction || 'null' ) + ' does not carry handle ' + handle + ' — refusing to send' );
8275
10276
 
8276
- const customer = ( orderCustomer || email || phone )
8277
- ? {
8278
- acceptsMarketing : orderCustomer?.email_marketing_consent?.state
8279
- ? orderCustomer.email_marketing_consent.state === 'subscribed'
8280
- : ( typeof orderCustomer?.accepts_marketing === 'boolean' ? orderCustomer.accepts_marketing : null ),
8281
- email : orderCustomer?.email || email || null,
8282
- firstName : orderCustomer?.first_name || null,
8283
- id : orderCustomer?.id ? String( orderCustomer.id ) : null,
8284
- lastName : orderCustomer?.last_name || null,
8285
- phone : customerPhone
8286
10277
  }
8287
- : null;
8288
10278
 
8289
- const source = connection?.source
8290
- ? { domain : connection.source.domain, id : connection.source.id }
8291
- : undefined;
10279
+ await shopify.billing.sendAppEvent({
10280
+ clientId,
10281
+ clientSecret,
10282
+ eventHandle : handle,
10283
+ idempotencyKey,
10284
+ // The Shopify-side event record names the order that produced it.
10285
+ reference : transaction,
10286
+ // OPERATOR-ONLY, BOTH OF THEM, and absent on every automatic
10287
+ // send. `revision` suffixes the idempotency key, which is what
10288
+ // makes a second attempt possible once a key has been consumed;
10289
+ // the encoding is owned by sendAppEvent so no caller builds keys
10290
+ // itself. `timestamp` pins the PURCHASE time, which the automatic
10291
+ // path must never do — see the note above — and the recovery
10292
+ // migration must, because it is resending orders from a closed
10293
+ // window and bounds its own run to the merchant's current cycle.
10294
+ ...( revision && { revision }),
10295
+ ...( timestamp && { timestamp }),
10296
+ shopId,
10297
+ value
10298
+ });
8292
10299
 
8293
- // EVERY PURCHASE GETS AN ORDER DOCUMENT, whichever way it reached us.
10300
+ return {
10301
+ message : 'Usage charge sent for order ' + orderId + '.',
10302
+ request : { order : order || null, orderId : orderId || null, shopId : shopId || null, value },
10303
+ result : { transaction : String( transaction ) },
10304
+ // TRACEABILITY. The App Events API returns no event id, so the
10305
+ // trace key into Shopify's event logs is the transaction id the
10306
+ // commerce.order hook minted and this sent as `reference`.
10307
+ // `amount` is the per-order usage value in cents, mirroring the
10308
+ // Stripe path's marker.
10309
+ ...( order && { writes : [ {
10310
+ collection : 'order',
10311
+ data : {
10312
+ $set : {
10313
+ billed : {
10314
+ amount : value,
10315
+ date : new Date(),
10316
+ rate : rate || 0,
10317
+ transaction : String( transaction )
10318
+ }
10319
+ }
10320
+ },
10321
+ operation : 'update',
10322
+ query : { id : order }
10323
+ } ] })
10324
+ };
10325
+
10326
+ },
10327
+
10328
+ // RESELECT, NOT CANCEL. The same mechanics — the current approval has to
10329
+ // be released before the pricing page will offer the plan again — but
10330
+ // the intent is a thirty-second round trip rather than a departure.
8294
10331
  //
8295
- // A redemption used to write only a redemption row, which meant the
8296
- // money existed on the Redemptions page and nowhere else: contact
8297
- // totals are summed from the ORDER collection, so a real purchase by a
8298
- // known entrant contributed nothing to their revenue and was invisible
8299
- // to every revenue segment. `type` keeps the two kinds apart for the
8300
- // figures that must stay causal (the fee, the Revenue page's
8301
- // conversion column) without splitting the source of truth in two.
8302
- const createsOrder = ! backfill && ( isConversion || Boolean( discount ) );
10332
+ // `mark` IS A DECLARED CAPABILITY, and this is the only slot here that
10333
+ // needs one. The marker has to be persisted BEFORE the approval is
10334
+ // released: it is what tells sync's CANCELLED handler to hold the pause
10335
+ // no connection error, no owner email and start a timed recheck that
10336
+ // pauses honestly if the merchant abandons. A described write lands
10337
+ // after the hook returns, which is after the cancel, and a marker
10338
+ // written then can race the very webhook it exists to soften. Same
10339
+ // precedent as `resolveContact`: the one write a description cannot
10340
+ // carry.
10341
+ reselect : async ( { context }, { mark, read, shopify = client } = {} ) => {
8303
10342
 
8304
- // MINTED HERE, because the redemption names its order and the usage
8305
- // job names both — a description cannot read a write's result, so the
8306
- // id exists before either does. Minted for the redemption too, so the
8307
- // order can name it back.
8308
- // MINTED AS A PAIR. `mintId` hands back both halves — the driver's `_id` and
8309
- // its string `id` — because this package has no mongodb dependency and
8310
- // cannot construct one. The string is what every reference below uses; the
8311
- // `_id` goes on the create so the document is born with the pair the
8312
- // controller would otherwise derive.
8313
- const mintedOrder = ! existingOrder?.id && createsOrder ? mintId() : null;
10343
+ const shop = sanitizeDomain( context?.shop );
8314
10344
 
8315
- const orderDocId = existingOrder?.id || mintedOrder?.id || null;
8316
- const mintedRedemption = discount ? mintId() : null;
10345
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8317
10346
 
8318
- const redemptionDocId = mintedRedemption?.id || null;
10347
+ await mark({ shop });
8319
10348
 
8320
- const writes = [];
10349
+ return releaseApprovals({ context, read, shopify });
8321
10350
 
8322
- if( createsOrder ){
10351
+ },
8323
10352
 
8324
- writes.push({
8325
- collection : 'order',
8326
- data : {
8327
- advertisement : advertisementId,
8328
- affiliate : affiliateId,
8329
- campaign : orderCampaign,
8330
- currency : currencyCode,
8331
- customer,
8332
- discount,
8333
- fee,
8334
- gross,
8335
- _id : mintedOrder?._id,
8336
- id : orderDocId,
8337
- lead : leadId,
8338
- lines,
8339
- net,
8340
- organization : campaignOrganization,
8341
- page : pageId,
8342
- provider : { id : String( orderId ), slug : 'shopify' },
8343
- purchasedAt,
8344
- rate,
8345
- // Null on a conversion that matched no code of ours; the
8346
- // backfill branch below sets it when one arrives later.
8347
- redemption : redemptionDocId,
8348
- source,
8349
- status : 'completed',
8350
- type : isConversion ? 'conversion' : 'redemption'
8351
- },
8352
- operation : 'create'
8353
- });
10353
+ // IS THIS STORE'S ORDER BILLING ACTUALLY METERED? Not a merchant
10354
+ // action — the other two slots here are — but the same subject, and
10355
+ // the reason it is a declared hook rather than a helper is that the
10356
+ // answer was being derived in two places: `lifecycle.health` below,
10357
+ // and drawbridge-sync's app_subscriptions/update handler, whose copy
10358
+ // stated in its own comment that it matched this one and did not.
10359
+ //
10360
+ // THREE ANSWERS, and the caller must keep them apart:
10361
+ //
10362
+ // a string PROVEN. The charge id or usage line id, stamped on
10363
+ // the connection as the evidence.
10364
+ // null PROBED, and nothing is accruing.
10365
+ // undefined NO VERDICT — the probe could not run. `probed` says
10366
+ // which, because undefined does not survive JSON.
10367
+ verify : async ( { context }, { adminToken, read, shopify = client } = {} ) => {
8354
10368
 
8355
- if( org?.usage ){
10369
+ const shop = sanitizeDomain( context?.shop );
8356
10370
 
8357
- writes.push({
8358
- collection : 'usage',
8359
- // TWO METERS, NOT ONE SUMMED. `revenue` has always meant
8360
- // conversion revenue and is the figure the fee is charged
8361
- // against, so redemption money gets its own key rather than
8362
- // changing what an existing number means.
8363
- data : { $inc : isConversion
8364
- ? { 'totals.revenue' : gross }
8365
- : { 'totals.redemptionRevenue' : gross }
8366
- },
8367
- operation : 'update',
8368
- query : { id : org.usage }
8369
- });
10371
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8370
10372
 
8371
- }
10373
+ const adminAccessToken = await adminToken();
8372
10374
 
8373
- if( leadId ){
10375
+ if( ! adminAccessToken ){
8374
10376
 
8375
- writes.push({
8376
- collection : 'lead',
8377
- // Same grouped shape the contact carries, so a lead and the
8378
- // contact built from it cannot be read two different ways.
8379
- data : { $inc : {
8380
- 'totals.orders.total' : 1,
8381
- ...( isConversion
8382
- ? { 'totals.orders.conversions' : 1 }
8383
- : { 'totals.orders.redemptions' : 1 }
8384
- )
8385
- } },
8386
- operation : 'update',
8387
- options : { bypassDocumentValidation : true },
8388
- query : { id : leadId }
8389
- });
10377
+ return { message : 'No admin token for this store — no verdict.', request : { shop }, result : { metered : null, probed : false }, skipped : true };
8390
10378
 
8391
10379
  }
8392
10380
 
8393
- }
8394
-
8395
- if( discount ){
10381
+ const partnerRow = read?.get ? await read.get({ collection : 'provider', query : { slug : 'shopify' } }) : null;
10382
+ const partner = partnerRow?.settings ? decrypt( partnerRow.settings ) : {};
8396
10383
 
8397
- writes.push({
8398
- collection : 'redemption',
8399
- data : {
8400
- advertisement : advertisementId,
8401
- affiliate : affiliateId,
8402
- campaign : orderCampaign,
8403
- code : matchedDiscounts[ 0 ]?.code || null,
8404
- currency : currencyCode,
8405
- customer,
8406
- discount,
8407
- gross,
8408
- _id : mintedRedemption?._id,
8409
- id : redemptionDocId,
8410
- lead : leadId,
8411
- order : orderDocId,
8412
- organization : campaignOrganization,
8413
- page : pageId,
8414
- provider : { id : String( orderId ), slug : 'shopify' },
8415
- purchasedAt,
8416
- source,
8417
- status : 'completed'
8418
- },
8419
- operation : 'create'
10384
+ const { ledger, metered } = await meterVerdict({
10385
+ adminAccessToken,
10386
+ ledgerFloor : new Date( Date.now() - LEDGER_WINDOW_MS ),
10387
+ partner,
10388
+ shop,
10389
+ shopId : context?.shopId || null,
10390
+ shopify
8420
10391
  });
8421
10392
 
8422
- if( org?.usage ){
10393
+ return {
10394
+ message : typeof metered === 'string' ? 'Billing is metered.' : 'No meter evidence for this store.',
10395
+ request : { shop },
10396
+ // `probed` is the LEDGER, not the call: a store whose Partner
10397
+ // credentials are absent was not asked, and reporting that as
10398
+ // "probed, nothing accruing" would stamp an unmetered verdict on
10399
+ // a store nobody looked at.
10400
+ result : { metered : metered ?? null, probed : ledger || typeof metered === 'string' }
10401
+ };
8423
10402
 
8424
- writes.push({
8425
- collection : 'usage',
8426
- data : { $inc : { 'totals.redemptions' : 1 } },
8427
- operation : 'update',
8428
- query : { id : org.usage }
8429
- });
10403
+ }
8430
10404
 
8431
- }
10405
+ },
8432
10406
 
8433
- if( leadId ){
10407
+ // THE EMBEDDED APP'S SESSION STORE. The @shopify/shopify-app library
10408
+ // persists the offline session on token exchange and refresh; it lives
10409
+ // encrypted on the shared `shop` record, which is the same row every
10410
+ // organization's connection to this store points at.
10411
+ session : {
8434
10412
 
8435
- writes.push({
8436
- collection : 'lead',
8437
- data : { $inc : { 'totals.redemptions' : 1 } },
8438
- operation : 'update',
8439
- options : { bypassDocumentValidation : true },
8440
- query : { id : leadId }
8441
- });
10413
+ // SOFT-CLEAR, NEVER A DELETE. The library calls this on session
10414
+ // invalidation and not only on uninstall, so hard-deleting the record
10415
+ // would orphan every org connection pointing at it token resolution
10416
+ // silently nulls — and destroy the durable account link and the
10417
+ // storefront token with it. The next embedded load re-runs token
10418
+ // exchange. The full record delete belongs to the uninstall webhook.
10419
+ clear : async ({ context }) => {
8442
10420
 
8443
- }
10421
+ const shop = sanitizeDomain( context?.shop );
8444
10422
 
8445
- // THE BACKFILL LEG: a conversion recorded earlier, matched to one of
8446
- // our codes now. The order already exists, so nothing above created
8447
- // it and nothing has told it which redemption it belongs to — this
8448
- // is the only write that closes that link. Skipped when the order
8449
- // was created in this same run, because it was minted carrying the
8450
- // id already.
8451
- if( backfill && orderDocId && redemptionDocId ){
10423
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8452
10424
 
8453
- writes.push({
8454
- collection : 'order',
8455
- data : { $set : { redemption : redemptionDocId } },
10425
+ return {
10426
+ message : 'Session cleared.',
10427
+ request : { shop },
10428
+ result : { ok : true },
10429
+ writes : [ {
10430
+ collection : 'shop',
10431
+ data : { $set : { settings : null } },
8456
10432
  operation : 'update',
8457
- query : { id : orderDocId }
8458
- });
10433
+ query : { shop }
10434
+ } ]
10435
+ };
8459
10436
 
8460
- }
10437
+ },
8461
10438
 
8462
- }
10439
+ read : async ( { context }, { read } = {} ) => {
8463
10440
 
8464
- // SHOPIFY-BILLED ORGS ARE CHARGED THROUGH SHOPIFY, keyed on the order
8465
- // id so a redelivery cannot charge twice. Never on a backfill: the fee
8466
- // was charged when the order was first recorded. Enqueues run after
8467
- // the transaction commits, so the job can never observe rows that
8468
- // roll back.
8469
- const billable = org?.billingProvider === 'shopify' && fee > 0 && ! backfill;
10441
+ const shop = sanitizeDomain( context?.shop );
8470
10442
 
8471
- // THE EFFECTIVE METER HANDLE. The provider row's orderEventHandle
8472
- // (admin-editable, for the day the plan config's meter changes)
8473
- // overrides the built-in default; slugified again on read as belt
8474
- // and braces with the save-side formatter, since a drifted stored
8475
- // value would mint transactions no meter classifies. Read directly
8476
- // (not via providerSettings — that module imports this one) and
8477
- // only for a billable order, which keeps the cost off every
8478
- // ordinary redemption.
8479
- const providerRow = billable
8480
- ? await read.get({ collection : 'provider', query : { slug : 'shopify' } })
8481
- : null;
10443
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8482
10444
 
8483
- const { orderEventHandle } = providerRow?.settings ? decrypt( providerRow.settings ) : {};
10445
+ const record = await read.get({ collection : 'shop', query : { shop } });
8484
10446
 
8485
- const handle = ( typeof orderEventHandle === 'string' && slugify( orderEventHandle ) ) || ORDER_EVENT_HANDLE;
10447
+ if( ! record?.settings ) throw refusal( 'No session for this store', 404 );
8486
10448
 
8487
- // A billable order with no shop id is revenue lost SILENTLY — the
8488
- // gate below just yields no job, the order records normally, and the
8489
- // connection looks healthy. An Error (not a warn) because this is
8490
- // money: the order id goes in the MESSAGE, since Sentry matches
8491
- // titles, and the health check's source self-heal is what makes the
8492
- // state rare enough to page on.
8493
- if( billable && ! connection?.source?.id ){
10449
+ return {
10450
+ message : 'Session read.',
10451
+ request : { shop },
10452
+ result : { session : decrypt( record.settings ) }
10453
+ };
8494
10454
 
8495
- logger?.error?.( new Error(
8496
- 'shopify.usage.billing.skipped on order ' + orderId + ': '
8497
- + Math.round( fee * 100 ) + ' cents not billed — connection '
8498
- + connection?.id + ' has no source.id'
8499
- ), {
8500
- extra : {
8501
- connectionId : connection?.id,
8502
- fee,
8503
- orderDocId,
8504
- orderId : String( orderId )
8505
- }
8506
- });
10455
+ },
8507
10456
 
8508
- }
10457
+ save : async ({ context }) => {
8509
10458
 
8510
- const enqueues = ( billable && connection?.source?.id )
8511
- ? [ {
8512
- data : {
8513
- // The meter handle travels WITH the job so the sender can
8514
- // verify it against the transaction prefix and refuse a
8515
- // drifted pair — idempotency keys are permanent, so a
8516
- // mistraceable event can never be resent under its own key.
8517
- handle,
8518
- idempotencyKey : String( orderId ),
8519
- orderDocId,
8520
- orderId : String( orderId ),
8521
- rate,
8522
- shopId : connection.source.id,
8523
- // The App Events API returns no event id, so one is generated
8524
- // here — the event handle plus the order id — and sent as the
8525
- // event's `reference`. queue/usage.js stamps the same id onto
8526
- // the order as billed.transaction.
8527
- transaction : handle + '.' + orderId,
8528
- value : Math.round( fee * 100 )
8529
- },
8530
- name : 'billing',
8531
- options : { jobId : 'shopify.usage.' + orderId },
8532
- queue : 'usage'
8533
- } ]
8534
- : [];
10459
+ const session = context?.session;
10460
+ const shop = sanitizeDomain( session?.shop );
8535
10461
 
8536
- return {
8537
- enqueues,
8538
- message : backfill
8539
- ? 'Redemption backfilled for an already-recorded order.'
8540
- : isConversion ? 'Order recorded.' : 'Discount redemption recorded (fee-free).',
8541
- request,
8542
- response : {
8543
- campaign : orderCampaign,
8544
- currency : currencyCode,
8545
- discount,
8546
- fee,
8547
- gross,
8548
- lead : leadId,
8549
- lines : lines.length,
8550
- net,
8551
- orderId : String( orderId )
8552
- },
8553
- // ONE TRANSACTION. The order, the redemption and both totals
8554
- // counters land together or not at all — a half-written attribution
8555
- // is revenue counted twice or not at all, and neither is
8556
- // recoverable by hand.
8557
- transaction : writes.length > 0,
8558
- writes
8559
- };
10462
+ if( ! shop || ! session?.accessToken ) throw refusal( 'session.shop and session.accessToken are required', 400 );
10463
+
10464
+ return {
10465
+ message : 'Session saved.',
10466
+ request : { shop },
10467
+ result : { ok : true },
10468
+ writes : [ {
10469
+ collection : 'shop',
10470
+ data : {
10471
+ $set : { settings : encrypt( session ), shop },
10472
+ $setOnInsert : { status : 'pending' }
10473
+ },
10474
+ operation : 'update',
10475
+ options : { upsert : true },
10476
+ query : { shop }
10477
+ } ]
10478
+ };
10479
+
10480
+ }
8560
10481
 
8561
10482
  },
8562
10483
 
8563
- // A PRODUCT CHANGED AT THE STORE. Upserts the product row and hands it to
8564
- // the product pipeline; the actual field sync happens there.
10484
+ // THE LINK-TOKEN HANDOFF. The breakout callback runs top-level and can set
10485
+ // a first-party cookie; the embedded App Home cannot read that cookie from
10486
+ // inside the Shopify admin iframe. So the callback stashes the merchant's
10487
+ // user token here keyed by shop, and App Home reads it once and clears it.
8565
10488
  //
8566
- // The shell has already refused a missing or inactive Shopify connection,
8567
- // so what is left is the two things only this hook can know are wrong.
8568
- product : async ( { connection, context, workflow }, { mintId, read } = {} ) => {
10489
+ // DURABLE RATHER THAN IN REDIS: a restart must not strand a merchant
10490
+ // mid-handoff. Short-lived and encrypted.
10491
+ token : {
8569
10492
 
8570
- const request = {
8571
- numericId : context?.id || null,
8572
- organizationId : workflow.organization,
8573
- title : context?.title || null
8574
- };
10493
+ clear : async ({ context }) => {
8575
10494
 
8576
- if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
10495
+ const shop = sanitizeDomain( context?.shop );
8577
10496
 
8578
- // The shop domain is half the identity below. Without it the upsert
8579
- // would match on provider id alone and could collide across stores.
8580
- if( ! connection.shop ) return { message : 'Skipped — Shopify connection is missing shop domain.', request, response : { skipped : true }, skipped : true };
10497
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8581
10498
 
8582
- const providerId = 'gid://shopify/Product/' + context.id;
10499
+ return {
10500
+ message : 'Link token cleared.',
10501
+ request : { shop },
10502
+ result : { ok : true },
10503
+ writes : [ {
10504
+ collection : 'shop',
10505
+ data : { $set : { linkToken : null, linkTokenExpiresAt : null } },
10506
+ operation : 'update',
10507
+ query : { shop }
10508
+ } ]
10509
+ };
8583
10510
 
8584
- // THE ROW'S OWN ID WHEN IT ALREADY EXISTS, a fresh one only when it
8585
- // does not.
8586
- //
8587
- // The write below is an upsert whose `id` sits in $setOnInsert, so a
8588
- // product we have seen before keeps the id it was first given and a
8589
- // newly minted one is silently discarded. Minting unconditionally
8590
- // therefore published an id belonging to no row: the enqueue named a
8591
- // product the worker then had to fall back off, and the step's
8592
- // response showed QA a value that matched nothing they could look up
8593
- // — which is exactly how it was reported.
8594
- //
8595
- // Reading first costs one indexed lookup on the same key the upsert
8596
- // matches. A concurrent redelivery can still have both runs miss and
8597
- // mint, which is why the job carries the PROVIDER identity too and the
8598
- // worker falls back to provider + shop — stable either way.
8599
- const query = {
8600
- 'provider.id' : providerId,
8601
- 'provider.slug' : 'shopify',
8602
- 'source.domain' : connection.shop
8603
- };
10511
+ },
8604
10512
 
8605
- const existing = read?.get ? await read.get({ collection : 'product', query }) : null;
10513
+ read : async ( { context }, { read } = {} ) => {
8606
10514
 
8607
- const mintedProduct = existing?.id ? null : mintId();
10515
+ const shop = sanitizeDomain( context?.shop );
8608
10516
 
8609
- const productId = existing?.id || mintedProduct.id;
10517
+ if( ! shop ) throw refusal( 'shop is required', 400 );
8610
10518
 
8611
- return {
8612
- enqueues : [ {
8613
- data : { product : productId, providerId, shop : connection.shop },
8614
- name : 'workflow',
8615
- // The numeric id, not the gid: BullMQ refuses a custom id with a
8616
- // colon in it, and every gid carries two.
8617
- options : { jobId : 'product.workflow.shopify.' + context.id + '.' + Date.now() },
8618
- queue : 'product.shopify'
8619
- } ],
8620
- message : 'Product sync queued from Shopify webhook.',
8621
- request,
8622
- response : { productId, providerId, title : context?.title || null },
8623
- // KEYED ON PROVIDER + SHOP, so the same product in two stores stays
8624
- // two rows. `connections` accumulates rather than replaces: one
8625
- // store can be linked to several organizations, and each keeps its
8626
- // own claim on the row.
8627
- writes : [ {
8628
- collection : 'product',
8629
- data : {
8630
- $addToSet : { connections : connection.id },
8631
- $setOnInsert : {
8632
- ...( mintedProduct && { _id : mintedProduct._id }),
8633
- id : productId,
8634
- provider : { id : providerId, slug : 'shopify' },
8635
- 'source.id' : connection.id,
8636
- status : 'active'
8637
- }
8638
- },
8639
- operation : 'update',
8640
- options : { upsert : true },
8641
- query
8642
- } ]
8643
- };
10519
+ const record = await read.get({ collection : 'shop', query : { shop } });
10520
+
10521
+ const expiresAt = record?.linkTokenExpiresAt ? new Date( record.linkTokenExpiresAt ) : null;
10522
+
10523
+ // EXPIRY IS CHECKED ON READ, not by a sweep. The token is one-shot
10524
+ // and short-lived, so the read is the only moment anyone cares.
10525
+ if( ! record?.linkToken || ! expiresAt || expiresAt <= new Date() ){
10526
+
10527
+ throw refusal( 'No link token for this store', 404 );
10528
+
10529
+ }
10530
+
10531
+ return {
10532
+ message : 'Link token read.',
10533
+ request : { shop },
10534
+ result : { token : decrypt( record.linkToken )?.token ?? null }
10535
+ };
10536
+
10537
+ },
10538
+
10539
+ save : async ({ context }) => {
10540
+
10541
+ const shop = sanitizeDomain( context?.shop );
10542
+ const token = context?.token;
10543
+
10544
+ if( ! shop || ! token ) throw refusal( 'shop and token are required', 400 );
10545
+
10546
+ return {
10547
+ message : 'Link token saved.',
10548
+ request : { shop },
10549
+ result : { ok : true },
10550
+ writes : [ {
10551
+ collection : 'shop',
10552
+ data : {
10553
+ $set : {
10554
+ linkToken : encrypt({ token }),
10555
+ linkTokenExpiresAt : new Date( Date.now() + LINK_TOKEN_TTL_MS )
10556
+ }
10557
+ },
10558
+ operation : 'update',
10559
+ query : { shop }
10560
+ } ]
10561
+ };
10562
+
10563
+ }
8644
10564
 
8645
10565
  }
8646
10566
 
8647
10567
  },
8648
- contacts : { remove : false, sync : false },
8649
-
8650
- // The embedded-app surface. Only a vendor whose merchants arrive from its
8651
- // own app store has one.
8652
- install : false,
8653
10568
 
8654
10569
  // Drawbridge sends its own notification email and SMS — see the private
8655
10570
  // `drawbridge` manifest. A vendor answering these would be a second sender,
@@ -8731,7 +10646,7 @@ var shopify = {
8731
10646
  // access token still works, reconciles the scopes the store granted
8732
10647
  // against the ones the app now needs, and queues a webhook
8733
10648
  // reconciliation.
8734
- health : async ( { connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {} ) => {
10649
+ health : async ( { connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify = client } = {} ) => {
8735
10650
 
8736
10651
  const request = {
8737
10652
  connectionId : workflow.connection,
@@ -8833,62 +10748,27 @@ var shopify = {
8833
10748
 
8834
10749
  }
8835
10750
 
8836
- const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
8837
- adminAccessToken,
8838
- domain : connection.shop
8839
- });
8840
-
8841
- // A usage line on the approval is POSITIVE evidence and gets
8842
- // stamped. Its ABSENCE proves nothing: measured 2026-09-04
8843
- // (both 2026-04 and unstable, against a plan whose meter
8844
- // exists), managed-pricing App-Events meters never
8845
- // materialize as subscription line items — AppUsagePricing is
8846
- // the legacy usage-charge model. Reading absence as
8847
- // "unmetered" flagged healthy stores, so absence is NO
8848
- // VERDICT: the stamp, the Issue, the card and the
8849
- // meter-missing pager all stay silent until a signal that can
8850
- // actually observe the meter exists (Partner API
8851
- // USAGE_CHARGE_APPLIED accrual events are that signal).
8852
- const usageLine = subscriptions.find( ( subscription ) => subscription.usageLineItemId )?.usageLineItemId;
8853
-
8854
- if( usageLine ) metered = usageLine;
8855
-
8856
- // THE REAL METER SIGNAL — Partner API accrual. A CHARGE_USAGE
8857
- // event for this shop is proof billing works: its charge id
8858
- // becomes the stamp (overriding the line-item probe, which
8859
- // managed pricing keeps blind), and each charge row is a
8860
- // billing BOUNDARY the reconciler below balances the books
8861
- // against. Off entirely until the provider row carries the
8862
- // credentials.
10751
+ // ONE READING OF THE TWO SIGNALS, shared with the approval
10752
+ // webhook in drawbridge-sync, which used to hand-copy it.
8863
10753
  const partnerRow = read?.get ? await read.get({ collection : 'provider', query : { slug : 'shopify' } }) : null;
8864
10754
  const partner = partnerRow?.settings ? decrypt( partnerRow.settings ) : {};
8865
10755
 
8866
- if( partner.partnerToken && partner.partnerOrgId && partner.partnerAppId && source?.id && shopify.partner?.getUsageChargeEvents ){
10756
+ const ledgerFloor = new Date( Date.now() - LEDGER_WINDOW_MS );
8867
10757
 
8868
- const ledgerFloor = new Date( Date.now() - 30 * 24 * 60 * 60 * 1000 );
8869
-
8870
- const accruals = await shopify.partner.getUsageChargeEvents({
8871
- appId : 'gid://shopify/App/' + partner.partnerAppId,
8872
- first : 10,
8873
- occurredAtMin : ledgerFloor.toISOString(),
8874
- organizationId : partner.partnerOrgId,
8875
- partnerToken : partner.partnerToken,
8876
- shopId : 'gid://shopify/Shop/' + source.id
8877
- });
10758
+ const verdict = await meterVerdict({
10759
+ adminAccessToken,
10760
+ ledgerFloor,
10761
+ partner,
10762
+ shop : connection.shop,
10763
+ shopId : source?.id,
10764
+ shopify
10765
+ });
8878
10766
 
8879
- // Oldest first: each charge row is a boundary that closes
8880
- // the window since the row before it.
8881
- const boundaries = accruals
8882
- .filter( ( event ) => event.occurredAt )
8883
- .sort( ( a, b ) => new Date( a.occurredAt ) - new Date( b.occurredAt ) );
10767
+ if( verdict.metered !== undefined ) metered = verdict.metered;
8884
10768
 
8885
- // A usage ROW exists per subscription even before anything
8886
- // accrues (measured: a pre-meter approval shows qty 0), so
8887
- // the row is not the proof — a POSITIVE usageQuantity is.
8888
- // The LATEST positive row is the stamp.
8889
- const accrued = [ ...boundaries ].reverse().find( ( event ) => Number( event.usageQuantity ) > 0 );
10769
+ const { boundaries } = verdict;
8890
10770
 
8891
- if( accrued ) metered = String( accrued.chargeId || accrued.id );
10771
+ if( boundaries.length ){
8892
10772
 
8893
10773
  // THE BOUNDARY RECONCILER. Money truth lives at boundaries:
8894
10774
  // each charge row states what Shopify actually charged for
@@ -9227,13 +11107,13 @@ var shopify = {
9227
11107
  // They are the same two questions every other vendor answers through
9228
11108
  // resources.*, so they answer them the same way now.
9229
11109
  //
9230
- // `shopify` is INJECTED: this package cannot import @drawbridge/shopify,
11110
+ // `shopify` DEFAULTS to the client this file imports; nothing injects it,
9231
11111
  // which depends on it. What arrives is the SDK's pure HTTP namespaces
9232
11112
  // and nothing else — no controller, no collection access. Resolving the
9233
11113
  // credential is the caller's job because it is Drawbridge's job: the
9234
11114
  // admin token refreshes and writes itself back, which is service work,
9235
11115
  // not vendor work.
9236
- products : async ( { cursor, limit = 100, search, settings, sort }, { shopify } = {} ) => {
11116
+ products : async ( { cursor, limit = 100, search, settings, sort }, { shopify = client } = {} ) => {
9237
11117
 
9238
11118
  const products = await shopify.storefront.getProducts({
9239
11119
  cursor,
@@ -9258,7 +11138,7 @@ var shopify = {
9258
11138
 
9259
11139
  },
9260
11140
 
9261
- promotions : async ( { cursor, limit = 100, search, settings }, { shopify } = {} ) => {
11141
+ promotions : async ( { cursor, limit = 100, search, settings }, { shopify = client } = {} ) => {
9262
11142
 
9263
11143
  const discounts = await shopify.admin.getDiscounts({
9264
11144
  adminAccessToken : settings?.adminAccessToken,
@@ -9683,14 +11563,15 @@ var webhook = {
9683
11563
  'Drawbridge can POST event payloads to your endpoint as activity happens in your account, so your own systems can react to it.',
9684
11564
  'Generate a signing secret and Drawbridge signs every request with it. Your endpoint recomputes the signature to confirm each payload genuinely came from Drawbridge before acting on it.'
9685
11565
  ],
9686
- // NO VENDOR-SPECIFIC CONNECT COPY, and that is a decision rather than a gap.
9687
- // The dashboard already carries generic reasons for every way an
9688
- // authorization can fail declined, expired state, a vendor we are not set
9689
- // up for and a sentence here is an ADDITION to those, for a vendor whose
9690
- // failures need saying differently. This one's do not.
11566
+ // THERE IS NO CONSENT SCREEN TO FAIL. Connecting mints a signing secret;
11567
+ // nothing is authorized against a third party, so neither `denied` nor
11568
+ // `invalid` has a state it could describe. The failures this connection
11569
+ // really has are at DELIVERY a merchant's endpoint refusing a POST and
11570
+ // those reach them through the connection's own errors rather than through
11571
+ // connect copy.
9691
11572
  //
9692
- // Answered rather than omitted, because `errors` existed on two of six
9693
- // manifests and there was no way to tell "nothing to add" from "nobody
11573
+ // `false` is the recorded decision, not silence: `errors` existed on two of
11574
+ // six manifests and there was no way to tell "nothing to add" from "nobody
9694
11575
  // wrote it".
9695
11576
  errors : false,
9696
11577
  excerpt : 'Sign outgoing webhook payloads with an HMAC secret to verify authenticity.',
@@ -11925,4 +13806,4 @@ const resolveConnection = ( item, data, env = {} ) => {
11925
13806
 
11926
13807
  };
11927
13808
 
11928
- export { tokenSettings as $, ANSWER_KEYS as A, hookSupport as B, isStale as C, isStatus as D, ERROR_SOURCES as E, mergeSettings as F, GROUPS as G, HOOKS as H, INPUTS as I, projectConnection as J, publicConnectionKeys as K, publicSettingsBySlug as L, redactSettings as M, resolveConnection as N, OAUTH_ENDPOINTS as O, PLATFORM_CREDENTIALS as P, runHook as Q, RETIRED as R, STATUSES as S, TRIGGERS as T, scopesMessage as U, stepLabels as V, WRITE_OPERATIONS as W, stepQueues as X, stepRoutes as Y, surviving as Z, systemSteps as _, AUTH_KINDS as a, vendors as a0, AUTH_TYPES as b, HOOK_CONFIG as c, HOOK_EFFECTS as d, HOOK_NAMES as e, HOOK_OPTIONS as f, HOOK_PROPS as g, HOOK_SLOT_PROPS as h, OAUTH_FIELDS as i, OUTCOMES as j, STEPS as k, STEP_TYPES as l, TRIGGER_TYPES as m, accessToken as n, answerOf as o, assess as p, availableConnections as q, build as r, buildVendor as s, catalogConnections as t, channels as u, connectFields as v, connectionSteps as w, connections as x, effectsOf as y, ensureSystemWorkflows as z };
13809
+ export { stepRoutes as $, ANSWER_KEYS as A, ensureSystemWorkflows as B, hookSupport as C, isStale as D, ERROR_SOURCES as E, isStatus as F, GROUPS as G, HOOKS as H, INPUTS as I, mergeSettings as J, perform as K, projectConnection as L, publicConnectionKeys as M, publicSettingsBySlug as N, OAUTH_ENDPOINTS as O, PLATFORM_CREDENTIALS as P, reconcileConnectionScopes as Q, RETIRED as R, STATUSES as S, TRIGGERS as T, redactSettings as U, resolveConnection as V, WRITE_OPERATIONS as W, runHook as X, scopesMessage as Y, stepLabels as Z, stepQueues as _, AUTH_KINDS as a, surviving as a0, systemSteps as a1, tokenSettings as a2, vendors as a3, AUTH_TYPES as b, HOOK_CONFIG as c, HOOK_EFFECTS as d, HOOK_NAMES as e, HOOK_OPTIONS as f, HOOK_PROPS as g, HOOK_SLOT_PROPS as h, OAUTH_FIELDS as i, OUTCOMES as j, STEPS as k, STEP_TYPES as l, TRIGGER_TYPES as m, accessToken as n, answerOf as o, assess as p, availableConnections as q, build as r, buildVendor as s, catalogConnections as t, channels as u, connectFields as v, connectionSettings as w, connectionSteps as x, connections as y, effectsOf as z };