@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.
- package/dist/admin-B9ZaLvan.d.cts +697 -0
- package/dist/admin-C3HtEM6h.d.ts +697 -0
- package/dist/billing-Bc4yo9XG.d.cts +175 -0
- package/dist/billing-mNsKflmQ.d.ts +175 -0
- package/dist/billing.d.cts +1 -1
- package/dist/billing.d.ts +1 -1
- package/dist/connections/index.cjs +3077 -228
- package/dist/connections/index.d.cts +13 -4
- package/dist/connections/index.d.ts +13 -4
- package/dist/connections/index.js +3107 -255
- package/dist/features.cjs +2940 -226
- package/dist/features.d.cts +13 -4
- package/dist/features.d.ts +13 -4
- package/dist/features.js +2980 -260
- package/dist/http.cjs +10 -1
- package/dist/http.d.cts +10 -1
- package/dist/http.d.ts +10 -1
- package/dist/http.js +10 -1
- package/dist/{index-B8JhYfvU.d.ts → index-B546oDNo.d.ts} +2837 -956
- package/dist/{index-qY18QITf.d.cts → index-C59xHago.d.cts} +2837 -956
- package/dist/oauth/index.d.cts +1 -1
- package/dist/oauth/index.d.ts +1 -1
- package/dist/oauth-BJDh0sdM.d.cts +527 -0
- package/dist/oauth-DveZMLHx.d.ts +527 -0
- package/dist/partner-BOZltuh2.d.ts +94 -0
- package/dist/partner-ed2OfW1J.d.cts +94 -0
- package/dist/plans.cjs +2939 -225
- package/dist/plans.d.cts +13 -4
- package/dist/plans.d.ts +13 -4
- package/dist/plans.js +2980 -260
- package/dist/pricing.cjs +2972 -258
- package/dist/pricing.d.cts +13 -4
- package/dist/pricing.d.ts +13 -4
- package/dist/pricing.js +2977 -257
- package/dist/providers.cjs +2942 -247
- package/dist/providers.d.cts +12 -3
- package/dist/providers.d.ts +12 -3
- package/dist/providers.js +2946 -245
- package/dist/sendgrid.cjs +10 -1
- package/dist/sendgrid.js +10 -1
- package/dist/shopify/admin.cjs +562 -0
- package/dist/shopify/admin.d.cts +3 -0
- package/dist/shopify/admin.d.ts +3 -0
- package/dist/shopify/admin.js +528 -0
- package/dist/shopify/billing.cjs +166 -0
- package/dist/shopify/billing.d.cts +3 -0
- package/dist/shopify/billing.d.ts +3 -0
- package/dist/shopify/billing.js +140 -0
- package/dist/shopify/constants.cjs +63 -0
- package/dist/shopify/constants.d.cts +58 -0
- package/dist/shopify/constants.d.ts +58 -0
- package/dist/shopify/constants.js +32 -0
- package/dist/shopify/oauth.cjs +509 -0
- package/dist/shopify/oauth.d.cts +7 -0
- package/dist/shopify/oauth.d.ts +7 -0
- package/dist/shopify/oauth.js +466 -0
- package/dist/shopify/partner.cjs +156 -0
- package/dist/shopify/partner.d.cts +3 -0
- package/dist/shopify/partner.d.ts +3 -0
- package/dist/shopify/partner.js +130 -0
- package/dist/shopify/storefront.cjs +611 -0
- package/dist/shopify/storefront.d.cts +3 -0
- package/dist/shopify/storefront.d.ts +3 -0
- package/dist/shopify/storefront.js +576 -0
- package/dist/storefront-C8FKOGeD.d.cts +659 -0
- package/dist/storefront-DJFGLqPl.d.ts +659 -0
- package/dist/twilio.cjs +10 -1
- package/dist/twilio.js +10 -1
- package/package.json +98 -68
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { isDuplicateKey } from './usage.js';
|
|
2
|
+
import { decrypt, encrypt } from './encrypt.js';
|
|
2
3
|
import { authToken } from './connections/oauth.js';
|
|
3
4
|
import { toE164, detectCountry } from './phone.js';
|
|
4
5
|
import crypto, { randomUUID, createHmac, timingSafeEqual, createVerify, createHash } from 'node:crypto';
|
|
5
6
|
import { request } from './http.js';
|
|
6
7
|
import { customAlphabet } from 'nanoid';
|
|
8
|
+
import { i as shopifyAdmin } from './admin-C3HtEM6h.js';
|
|
9
|
+
import { a as shopifyBilling } from './billing-mNsKflmQ.js';
|
|
10
|
+
import { s as shopifyOauth } from './oauth-DveZMLHx.js';
|
|
11
|
+
import { s as shopifyPartner } from './partner-BOZltuh2.js';
|
|
12
|
+
import { s as shopifyStorefront } from './storefront-DJFGLqPl.js';
|
|
7
13
|
import { toCanonicalEmail } from './email.js';
|
|
8
|
-
import {
|
|
14
|
+
import { tokenTypes, hashToken } from './oauth/index.js';
|
|
15
|
+
import { sanitizeDomain } from './sanitize.js';
|
|
9
16
|
import { slugify } from './slugify.js';
|
|
10
17
|
import { safeRequest } from './safe-http.js';
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
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
|
-
|
|
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,
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
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' :
|
|
653
|
-
|
|
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' :
|
|
656
|
-
'
|
|
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
|
-
|
|
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 }, {
|
|
2071
|
-
|
|
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
|
-
//
|
|
2715
|
-
//
|
|
2716
|
-
//
|
|
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
|
-
//
|
|
2721
|
-
//
|
|
2722
|
-
//
|
|
2723
|
-
errors :
|
|
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
|
-
//
|
|
4259
|
-
//
|
|
4260
|
-
//
|
|
4261
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
6611
|
-
//
|
|
6612
|
-
//
|
|
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
|
-
//
|
|
6617
|
-
//
|
|
6618
|
-
//
|
|
6619
|
-
errors :
|
|
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
|
-
//
|
|
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
|
-
//
|
|
7628
|
-
|
|
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
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
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
|
-
|
|
7709
|
-
// same store points at the same row.
|
|
7710
|
-
collection : 'shop',
|
|
8166
|
+
const active = ( subscriptions || [] ).length > 0;
|
|
7711
8167
|
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7730
|
-
|
|
7731
|
-
|
|
7732
|
-
|
|
7733
|
-
|
|
7734
|
-
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
|
|
7738
|
-
|
|
7739
|
-
|
|
7740
|
-
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
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
|
-
//
|
|
7761
|
-
//
|
|
7762
|
-
//
|
|
7763
|
-
//
|
|
7764
|
-
//
|
|
7765
|
-
//
|
|
7766
|
-
//
|
|
7767
|
-
//
|
|
7768
|
-
//
|
|
7769
|
-
|
|
7770
|
-
|
|
7771
|
-
|
|
7772
|
-
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
7800
|
-
|
|
7801
|
-
|
|
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
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
7811
|
-
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7815
|
-
|
|
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
|
-
|
|
7818
|
-
|
|
7819
|
-
|
|
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
|
|
7822
|
-
//
|
|
7823
|
-
//
|
|
7824
|
-
//
|
|
7825
|
-
//
|
|
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
|
-
//
|
|
7828
|
-
//
|
|
7829
|
-
|
|
7830
|
-
//
|
|
7831
|
-
//
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7862
|
-
|
|
7863
|
-
|
|
7864
|
-
|
|
7865
|
-
|
|
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
|
-
|
|
9664
|
+
if( ! context?.id ) return { message : 'Skipped — product webhook payload had no id.', request, response : { skipped : true }, skipped : true };
|
|
7870
9665
|
|
|
7871
|
-
//
|
|
7872
|
-
//
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
//
|
|
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
|
-
//
|
|
7880
|
-
//
|
|
7881
|
-
//
|
|
7882
|
-
//
|
|
7883
|
-
//
|
|
7884
|
-
//
|
|
7885
|
-
//
|
|
7886
|
-
//
|
|
7887
|
-
//
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
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 : {
|
|
7905
|
-
//
|
|
7906
|
-
//
|
|
7907
|
-
//
|
|
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 : '
|
|
9716
|
+
collection : 'product',
|
|
7910
9717
|
data : {
|
|
7911
|
-
$
|
|
7912
|
-
|
|
7913
|
-
|
|
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 : {
|
|
7918
|
-
query
|
|
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
|
-
|
|
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
|
-
|
|
9765
|
+
if( ! shop || ! token ) throw refusal( 'shop and token are required', 400 );
|
|
7933
9766
|
|
|
7934
|
-
|
|
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
|
-
|
|
7938
|
-
|
|
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
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
//
|
|
7946
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7954
|
-
|
|
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
|
-
|
|
7958
|
-
|
|
7959
|
-
|
|
7960
|
-
|
|
7961
|
-
|
|
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
|
-
|
|
9874
|
+
},
|
|
7967
9875
|
|
|
7968
|
-
|
|
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
|
-
|
|
7975
|
-
|
|
7976
|
-
|
|
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
|
-
|
|
9882
|
+
const organization = context?.organization;
|
|
9883
|
+
const shop = sanitizeDomain( context?.shop );
|
|
7984
9884
|
|
|
7985
|
-
|
|
7986
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8024
|
-
|
|
8025
|
-
|
|
8026
|
-
|
|
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
|
-
|
|
9897
|
+
if( ! org ) throw refusal( 'No access to this organization', 403 );
|
|
8036
9898
|
|
|
8037
|
-
|
|
8038
|
-
|
|
8039
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9915
|
+
throw refusal( 'This organization is already connected to another Shopify store', 409 );
|
|
8060
9916
|
|
|
8061
|
-
|
|
8062
|
-
const { attrMap, attributedGross, attributedLines } = attributeLineItems( lineItems );
|
|
9917
|
+
}
|
|
8063
9918
|
|
|
8064
|
-
|
|
9919
|
+
const settings = record?.settings ? decrypt( record.settings ) : null;
|
|
8065
9920
|
|
|
8066
|
-
|
|
8067
|
-
const codes = [ ...new Set( discountCodes.map( ( dc ) => dc?.code ).filter( Boolean ) ) ];
|
|
9921
|
+
if( ! settings?.accessToken ){
|
|
8068
9922
|
|
|
8069
|
-
|
|
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
|
-
|
|
9925
|
+
}
|
|
8077
9926
|
|
|
8078
|
-
|
|
9927
|
+
const plan = await storePlan({ record, shopify });
|
|
8079
9928
|
|
|
8080
|
-
if(
|
|
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
|
-
|
|
8085
|
-
|
|
8086
|
-
.
|
|
8087
|
-
|
|
8088
|
-
|
|
8089
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9967
|
+
const shopData = await shopify.oauth.getShop({ adminAccessToken : settings.accessToken, shop });
|
|
8109
9968
|
|
|
8110
|
-
|
|
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
|
-
|
|
9974
|
+
}
|
|
8121
9975
|
|
|
8122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8147
|
-
|
|
8148
|
-
|
|
8149
|
-
|
|
8150
|
-
|
|
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
|
-
|
|
10069
|
+
if( ! shop ) throw refusal( 'shop is required', 400 );
|
|
8153
10070
|
|
|
8154
|
-
const
|
|
10071
|
+
const record = await read.get({ collection : 'shop', query : { shop } });
|
|
8155
10072
|
|
|
8156
|
-
|
|
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 : '
|
|
8173
|
-
request,
|
|
8174
|
-
|
|
8175
|
-
|
|
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
|
-
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8188
|
-
|
|
8189
|
-
|
|
8190
|
-
|
|
8191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8203
|
-
|
|
8204
|
-
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
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
|
-
|
|
8215
|
-
|
|
8216
|
-
if( ! leadId ){
|
|
10159
|
+
const verdicts = ( stamped || [] ).map( ( row ) => row?.source?.metered );
|
|
8217
10160
|
|
|
8218
|
-
|
|
8219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8232
|
-
|
|
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
|
-
|
|
10194
|
+
if( ! shop ) throw refusal( 'shop is required', 400 );
|
|
10195
|
+
if( ! organization ) throw refusal( 'organization is required', 400 );
|
|
8238
10196
|
|
|
8239
|
-
|
|
10197
|
+
const record = await read.get({ collection : 'shop', query : { shop } });
|
|
8240
10198
|
|
|
8241
|
-
|
|
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
|
-
|
|
10205
|
+
if( ! ( owned || [] ).some( ( entry ) => entry.id === organization ) ){
|
|
8247
10206
|
|
|
8248
|
-
|
|
10207
|
+
throw refusal( 'No access to this organization', 403 );
|
|
8249
10208
|
|
|
8250
|
-
|
|
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
|
|
10211
|
+
const connection = await read.get({
|
|
10212
|
+
collection : 'connection',
|
|
10213
|
+
query : { organization, shop, slug : 'shopify' }
|
|
10214
|
+
});
|
|
8265
10215
|
|
|
8266
|
-
|
|
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
|
-
|
|
8271
|
-
const net = Math.round( ( gross - fee ) * 100 ) / 100;
|
|
10227
|
+
},
|
|
8272
10228
|
|
|
8273
|
-
|
|
8274
|
-
|
|
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
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
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
|
-
|
|
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
|
|
8296
|
-
//
|
|
8297
|
-
//
|
|
8298
|
-
//
|
|
8299
|
-
//
|
|
8300
|
-
//
|
|
8301
|
-
//
|
|
8302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8316
|
-
const mintedRedemption = discount ? mintId() : null;
|
|
10345
|
+
if( ! shop ) throw refusal( 'shop is required', 400 );
|
|
8317
10346
|
|
|
8318
|
-
|
|
10347
|
+
await mark({ shop });
|
|
8319
10348
|
|
|
8320
|
-
|
|
10349
|
+
return releaseApprovals({ context, read, shopify });
|
|
8321
10350
|
|
|
8322
|
-
|
|
10351
|
+
},
|
|
8323
10352
|
|
|
8324
|
-
|
|
8325
|
-
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
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
|
-
|
|
10369
|
+
const shop = sanitizeDomain( context?.shop );
|
|
8356
10370
|
|
|
8357
|
-
|
|
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(
|
|
10375
|
+
if( ! adminAccessToken ){
|
|
8374
10376
|
|
|
8375
|
-
|
|
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
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8401
|
-
|
|
8402
|
-
|
|
8403
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8436
|
-
|
|
8437
|
-
|
|
8438
|
-
|
|
8439
|
-
|
|
8440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
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 : {
|
|
8458
|
-
}
|
|
10433
|
+
query : { shop }
|
|
10434
|
+
} ]
|
|
10435
|
+
};
|
|
8459
10436
|
|
|
8460
|
-
|
|
10437
|
+
},
|
|
8461
10438
|
|
|
8462
|
-
}
|
|
10439
|
+
read : async ( { context }, { read } = {} ) => {
|
|
8463
10440
|
|
|
8464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10445
|
+
const record = await read.get({ collection : 'shop', query : { shop } });
|
|
8484
10446
|
|
|
8485
|
-
|
|
10447
|
+
if( ! record?.settings ) throw refusal( 'No session for this store', 404 );
|
|
8486
10448
|
|
|
8487
|
-
|
|
8488
|
-
|
|
8489
|
-
|
|
8490
|
-
|
|
8491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8537
|
-
|
|
8538
|
-
|
|
8539
|
-
|
|
8540
|
-
|
|
8541
|
-
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
8550
|
-
|
|
8551
|
-
|
|
8552
|
-
}
|
|
8553
|
-
|
|
8554
|
-
|
|
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
|
-
//
|
|
8564
|
-
//
|
|
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
|
-
//
|
|
8567
|
-
//
|
|
8568
|
-
|
|
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
|
-
|
|
8571
|
-
numericId : context?.id || null,
|
|
8572
|
-
organizationId : workflow.organization,
|
|
8573
|
-
title : context?.title || null
|
|
8574
|
-
};
|
|
10493
|
+
clear : async ({ context }) => {
|
|
8575
10494
|
|
|
8576
|
-
|
|
10495
|
+
const shop = sanitizeDomain( context?.shop );
|
|
8577
10496
|
|
|
8578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10513
|
+
read : async ( { context }, { read } = {} ) => {
|
|
8606
10514
|
|
|
8607
|
-
|
|
10515
|
+
const shop = sanitizeDomain( context?.shop );
|
|
8608
10516
|
|
|
8609
|
-
|
|
10517
|
+
if( ! shop ) throw refusal( 'shop is required', 400 );
|
|
8610
10518
|
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
//
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
8623
|
-
|
|
8624
|
-
|
|
8625
|
-
|
|
8626
|
-
|
|
8627
|
-
|
|
8628
|
-
|
|
8629
|
-
|
|
8630
|
-
|
|
8631
|
-
|
|
8632
|
-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
8641
|
-
|
|
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
|
-
|
|
8837
|
-
|
|
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
|
-
|
|
10756
|
+
const ledgerFloor = new Date( Date.now() - LEDGER_WINDOW_MS );
|
|
8867
10757
|
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
9687
|
-
//
|
|
9688
|
-
//
|
|
9689
|
-
//
|
|
9690
|
-
//
|
|
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
|
-
//
|
|
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 {
|
|
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 };
|