@drawbridge/drawbridge-utils 0.0.143 → 0.0.145

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.
@@ -3366,6 +3366,26 @@ var toCanonicalEmail = (value) => {
3366
3366
  return local + "@" + domain;
3367
3367
  };
3368
3368
 
3369
+ // lib/encrypt.js
3370
+ var import_crypto = __toESM(require("crypto"), 1);
3371
+ var ALGORITHM = "aes-256-gcm";
3372
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
3373
+ var decrypt = (value) => {
3374
+ if (typeof value !== "string") return value;
3375
+ const [ivHex, tagHex, dataHex] = value.split(":");
3376
+ const decipher = import_crypto.default.createDecipheriv(
3377
+ ALGORITHM,
3378
+ getKey(),
3379
+ Buffer.from(ivHex, "hex")
3380
+ );
3381
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
3382
+ const result = Buffer.concat([
3383
+ decipher.update(Buffer.from(dataHex, "hex")),
3384
+ decipher.final()
3385
+ ]);
3386
+ return JSON.parse(result.toString("utf8"));
3387
+ };
3388
+
3369
3389
  // lib/slugify.js
3370
3390
  var import_slugify = __toESM(require("slugify"), 1);
3371
3391
  var slugify = (value, nochars = false) => {
@@ -3420,6 +3440,8 @@ var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFG
3420
3440
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3421
3441
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3422
3442
  var OAUTH_ERROR_SOURCE = "oauth";
3443
+ var BILLING_ERROR_SOURCE = "billing";
3444
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
3423
3445
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
3424
3446
  var inbound = {
3425
3447
  headers: {
@@ -3940,6 +3962,9 @@ var shopify_default2 = {
3940
3962
  }
3941
3963
  }
3942
3964
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3965
+ const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
3966
+ const { orderEventHandle } = (providerRow == null ? void 0 : providerRow.settings) ? decrypt(providerRow.settings) : {};
3967
+ const handle = typeof orderEventHandle === "string" && slugify(orderEventHandle) || ORDER_EVENT_HANDLE;
3943
3968
  if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3944
3969
  (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3945
3970
  "shopify.usage.billing.skipped on order " + orderId + ": " + Math.round(fee * 100) + " cents not billed \u2014 connection " + (connection2 == null ? void 0 : connection2.id) + " has no source.id"
@@ -3958,7 +3983,7 @@ var shopify_default2 = {
3958
3983
  // verify it against the transaction prefix and refuse a
3959
3984
  // drifted pair — idempotency keys are permanent, so a
3960
3985
  // mistraceable event can never be resent under its own key.
3961
- handle: ORDER_EVENT_HANDLE,
3986
+ handle,
3962
3987
  idempotencyKey: String(orderId),
3963
3988
  orderDocId,
3964
3989
  orderId: String(orderId),
@@ -3968,7 +3993,7 @@ var shopify_default2 = {
3968
3993
  // here — the event handle plus the order id — and sent as the
3969
3994
  // event's `reference`. queue/usage.js stamps the same id onto
3970
3995
  // the order as billed.transaction.
3971
- transaction: ORDER_EVENT_HANDLE + "." + orderId,
3996
+ transaction: handle + "." + orderId,
3972
3997
  value: Math.round(fee * 100)
3973
3998
  },
3974
3999
  name: "billing",
@@ -4173,6 +4198,46 @@ var shopify_default2 = {
4173
4198
  query: { id: connection2.id }
4174
4199
  });
4175
4200
  }
4201
+ const hasBillingIssue = (connection2.errors || []).some((entry) => entry.source === BILLING_ERROR_SOURCE);
4202
+ if (metered === null && !hasBillingIssue) {
4203
+ writes.push({
4204
+ collection: "connection",
4205
+ data: {
4206
+ $push: {
4207
+ errors: {
4208
+ message: BILLING_ERROR_MESSAGE,
4209
+ source: BILLING_ERROR_SOURCE
4210
+ }
4211
+ }
4212
+ },
4213
+ operation: "update",
4214
+ query: { id: connection2.id }
4215
+ });
4216
+ }
4217
+ if (typeof metered === "string" && hasBillingIssue) {
4218
+ writes.push({
4219
+ collection: "connection",
4220
+ data: {
4221
+ $pull: {
4222
+ errors: { source: BILLING_ERROR_SOURCE }
4223
+ }
4224
+ },
4225
+ operation: "update",
4226
+ query: { id: connection2.id }
4227
+ });
4228
+ writes.push({
4229
+ collection: "connection",
4230
+ data: {
4231
+ $set: { status: "active" }
4232
+ },
4233
+ operation: "update",
4234
+ query: {
4235
+ id: connection2.id,
4236
+ status: "error",
4237
+ "errors.0": { $exists: false }
4238
+ }
4239
+ });
4240
+ }
4176
4241
  return {
4177
4242
  writes,
4178
4243
  enqueues: [{
@@ -4337,7 +4402,15 @@ var shopify_default2 = {
4337
4402
  { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", required: true },
4338
4403
  { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
4339
4404
  { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
4340
- { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
4405
+ { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true },
4406
+ // The usage meter's event handle, when the plan config's meter ever
4407
+ // changes: stored value overrides the built-in default
4408
+ // (events.order.handle) at the mint point, and `format : 'slug'`
4409
+ // normalizes it on save — a handle can carry no spaces or capitals,
4410
+ // and classification against it is case-sensitive. Optional: empty
4411
+ // means the default, and the sender still refuses any handle that
4412
+ // disagrees with the transaction it minted.
4413
+ { format: "slug", input: "text", key: "orderEventHandle", label: "Order event handle", required: false, setting: true }
4341
4414
  ]
4342
4415
  },
4343
4416
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -4355,9 +4428,18 @@ var shopify_default2 = {
4355
4428
  // there is nothing further to choose. `shop` absent means the install did not
4356
4429
  // finish, which is a credential problem rather than a setup one, so the
4357
4430
  // stored status already says so.
4358
- // Nothing to add — no setting can make this connection unusable, so the
4359
- // credential's own verdict stands.
4360
- status: (data2) => data2 == null ? void 0 : data2.status,
4431
+ //
4432
+ // ONE presentation downgrade: an ACTIVE store whose approval carries no
4433
+ // usage meter (source.metered explicitly null) PRESENTS as error, so the
4434
+ // connections list shows the red state and the merchant knows to click in
4435
+ // — where the Fix-order-billing card and the billing Issue explain the
4436
+ // click. The STORED status stays active on purpose: serving and tracking
4437
+ // gate on it, and the orders recorded while the merchant fixes the plan
4438
+ // are the ones billed afterward.
4439
+ status: (data2) => {
4440
+ var _a;
4441
+ return (data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === null ? "error" : data2 == null ? void 0 : data2.status;
4442
+ },
4361
4443
  // Step types name the CAPABILITY, not this vendor. A second store platform
4362
4444
  // implements the same four commerce steps, and the connection on the step
4363
4445
  // says which store it runs against — so a merchant sees one "Create
@@ -4479,8 +4561,9 @@ var shopify_default2 = {
4479
4561
  ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4480
4562
  {
4481
4563
  action: "billing",
4482
- message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4483
- title: "Approve your plan"
4564
+ message: "Approve the Drawbridge plan to activate this connection. You'll be asked to select a plan on Shopify, then everything else happens here.",
4565
+ title: "Approve your plan",
4566
+ type: "warning"
4484
4567
  }
4485
4568
  ] : [],
4486
4569
  // The store's ACTIVE approval carries no usage component — stamped by
@@ -4493,8 +4576,9 @@ var shopify_default2 = {
4493
4576
  ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === null ? [
4494
4577
  {
4495
4578
  action: "billing",
4496
- message: "Order billing isn't set up for this store, so campaign-attributed orders aren't being charged. Approve the updated plan to fix it \u2014 one click here, one confirmation on Shopify.",
4497
- title: "Fix order billing"
4579
+ message: "Order billing isn't set up for this store, so campaign-attributed orders aren't being charged. You'll need to reselect your plan on Shopify and approve it to fix this.",
4580
+ title: "Fix order billing",
4581
+ type: "error"
4498
4582
  }
4499
4583
  ] : []
4500
4584
  ];
@@ -5100,6 +5184,11 @@ var publicConnectionKeys = Object.freeze([
5100
5184
  "settings",
5101
5185
  "shop",
5102
5186
  "slug",
5187
+ // Vendor identity plus the metered billing stamp — domain, id, label,
5188
+ // metered — no credentials live here (those are the encrypted settings
5189
+ // blob). The status and tasks hooks pivot on source.metered, so stripping
5190
+ // it here left the presented status blind to the one state it must show.
5191
+ "source",
5103
5192
  "status",
5104
5193
  "tasks",
5105
5194
  "title",
@@ -7,6 +7,7 @@ import { channels } from '../pricing.cjs';
7
7
  import { customAlphabet } from 'nanoid';
8
8
  import { toCanonicalEmail } from '../email.cjs';
9
9
  import { conversionRate } from '../plans.cjs';
10
+ import { decrypt } from '../encrypt.cjs';
10
11
  import { slugify } from '../slugify.cjs';
11
12
  import { safeRequest } from '../safe-http.cjs';
12
13
  import 'libphonenumber-js';
@@ -19,6 +20,8 @@ import '../index.cjs';
19
20
  import 'currency-codes';
20
21
  import '../color.cjs';
21
22
  import 'tinycolor2';
23
+ import 'crypto';
24
+ import '../token.cjs';
22
25
  import 'slugify';
23
26
  import 'dns';
24
27
  import 'node:http';
@@ -3869,6 +3872,13 @@ const REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1000;
3869
3872
 
3870
3873
  const OAUTH_ERROR_SOURCE = 'oauth';
3871
3874
 
3875
+ // The billing Issue a probed-unmetered store carries in its connection
3876
+ // errors, written and cleared beside the source.metered stamp (health check
3877
+ // and drawbridge-sync's approval restamp share the source tag). One string,
3878
+ // used by both writers, so the entries dedupe and $pull cleanly.
3879
+ const BILLING_ERROR_SOURCE = 'billing';
3880
+ const BILLING_ERROR_MESSAGE = 'Order billing isn\'t set up for this store — reselect and approve the plan from the Shopify connection page.';
3881
+
3872
3882
  // What Shopify says when the merchant has uninstalled or revoked. Neither is
3873
3883
  // retryable and both mean the same thing to a merchant: reconnect.
3874
3884
  const OAUTH_GRANT_REVOKED_CODES = [ 'application_cannot_be_found', 'invalid_grant' ];
@@ -4592,6 +4602,22 @@ var shopify = {
4592
4602
  // roll back.
4593
4603
  const billable = org?.billingProvider === 'shopify' && fee > 0 && ! backfill;
4594
4604
 
4605
+ // THE EFFECTIVE METER HANDLE. The provider row's orderEventHandle
4606
+ // (admin-editable, for the day the plan config's meter changes)
4607
+ // overrides the built-in default; slugified again on read as belt
4608
+ // and braces with the save-side formatter, since a drifted stored
4609
+ // value would mint transactions no meter classifies. Read directly
4610
+ // (not via providerSettings — that module imports this one) and
4611
+ // only for a billable order, which keeps the cost off every
4612
+ // ordinary redemption.
4613
+ const providerRow = billable
4614
+ ? await read.get({ collection : 'provider', query : { slug : 'shopify' } })
4615
+ : null;
4616
+
4617
+ const { orderEventHandle } = providerRow?.settings ? decrypt( providerRow.settings ) : {};
4618
+
4619
+ const handle = ( typeof orderEventHandle === 'string' && slugify( orderEventHandle ) ) || ORDER_EVENT_HANDLE;
4620
+
4595
4621
  // A billable order with no shop id is revenue lost SILENTLY — the
4596
4622
  // gate below just yields no job, the order records normally, and the
4597
4623
  // connection looks healthy. An Error (not a warn) because this is
@@ -4622,7 +4648,7 @@ var shopify = {
4622
4648
  // verify it against the transaction prefix and refuse a
4623
4649
  // drifted pair — idempotency keys are permanent, so a
4624
4650
  // mistraceable event can never be resent under its own key.
4625
- handle : ORDER_EVENT_HANDLE,
4651
+ handle,
4626
4652
  idempotencyKey : String( orderId ),
4627
4653
  orderDocId,
4628
4654
  orderId : String( orderId ),
@@ -4632,7 +4658,7 @@ var shopify = {
4632
4658
  // here — the event handle plus the order id — and sent as the
4633
4659
  // event's `reference`. queue/usage.js stamps the same id onto
4634
4660
  // the order as billed.transaction.
4635
- transaction : ORDER_EVENT_HANDLE + '.' + orderId,
4661
+ transaction : handle + '.' + orderId,
4636
4662
  value : Math.round( fee * 100 )
4637
4663
  },
4638
4664
  name : 'billing',
@@ -4962,6 +4988,63 @@ var shopify = {
4962
4988
 
4963
4989
  }
4964
4990
 
4991
+ // THE ISSUE MARKER rides beside the stamp: a probed-unmetered
4992
+ // store carries a billing entry in the connection's own errors —
4993
+ // the Issues surface a merchant already knows — and a metered
4994
+ // verdict clears it. Keyed on PRESENCE rather than the verdict
4995
+ // changing, so a connection stamped before this writer existed
4996
+ // still gains its entry on the next run. Status is deliberately
4997
+ // untouched on the way in (orders must keep recording), and the
4998
+ // way out mirrors the resume idiom: reactivate only a connection
4999
+ // that is errored with nothing else wrong.
5000
+ const hasBillingIssue = ( connection.errors || [] ).some( ( entry ) => entry.source === BILLING_ERROR_SOURCE );
5001
+
5002
+ if( metered === null && ! hasBillingIssue ){
5003
+
5004
+ writes.push({
5005
+ collection : 'connection',
5006
+ data : {
5007
+ $push : {
5008
+ errors : {
5009
+ message : BILLING_ERROR_MESSAGE,
5010
+ source : BILLING_ERROR_SOURCE
5011
+ }
5012
+ }
5013
+ },
5014
+ operation : 'update',
5015
+ query : { id : connection.id }
5016
+ });
5017
+
5018
+ }
5019
+
5020
+ if( typeof metered === 'string' && hasBillingIssue ){
5021
+
5022
+ writes.push({
5023
+ collection : 'connection',
5024
+ data : {
5025
+ $pull : {
5026
+ errors : { source : BILLING_ERROR_SOURCE }
5027
+ }
5028
+ },
5029
+ operation : 'update',
5030
+ query : { id : connection.id }
5031
+ });
5032
+
5033
+ writes.push({
5034
+ collection : 'connection',
5035
+ data : {
5036
+ $set : { status : 'active' }
5037
+ },
5038
+ operation : 'update',
5039
+ query : {
5040
+ id : connection.id,
5041
+ status : 'error',
5042
+ 'errors.0' : { $exists : false }
5043
+ }
5044
+ });
5045
+
5046
+ }
5047
+
4965
5048
  return {
4966
5049
  writes,
4967
5050
  enqueues : [ {
@@ -5174,7 +5257,15 @@ var shopify = {
5174
5257
  { input : 'text', key : 'apiKey', credential : 'SHOPIFY_API_KEY', label : 'API key', required : true },
5175
5258
  { input : 'password', key : 'apiSecret', credential : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
5176
5259
  { input : 'text', key : 'appHandle', credential : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
5177
- { input : 'text', key : 'listingUrl', credential : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
5260
+ { input : 'text', key : 'listingUrl', credential : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true },
5261
+ // The usage meter's event handle, when the plan config's meter ever
5262
+ // changes: stored value overrides the built-in default
5263
+ // (events.order.handle) at the mint point, and `format : 'slug'`
5264
+ // normalizes it on save — a handle can carry no spaces or capitals,
5265
+ // and classification against it is case-sensitive. Optional: empty
5266
+ // means the default, and the sender still refuses any handle that
5267
+ // disagrees with the transaction it minted.
5268
+ { format : 'slug', input : 'text', key : 'orderEventHandle', label : 'Order event handle', required : false, setting : true }
5178
5269
  ]
5179
5270
  },
5180
5271
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -5192,9 +5283,19 @@ var shopify = {
5192
5283
  // there is nothing further to choose. `shop` absent means the install did not
5193
5284
  // finish, which is a credential problem rather than a setup one, so the
5194
5285
  // stored status already says so.
5195
- // Nothing to add — no setting can make this connection unusable, so the
5196
- // credential's own verdict stands.
5197
- status : ( data ) => data?.status,
5286
+ //
5287
+ // ONE presentation downgrade: an ACTIVE store whose approval carries no
5288
+ // usage meter (source.metered explicitly null) PRESENTS as error, so the
5289
+ // connections list shows the red state and the merchant knows to click in
5290
+ // — where the Fix-order-billing card and the billing Issue explain the
5291
+ // click. The STORED status stays active on purpose: serving and tracking
5292
+ // gate on it, and the orders recorded while the merchant fixes the plan
5293
+ // are the ones billed afterward.
5294
+ status : ( data ) => (
5295
+ data?.status === 'active' && data?.source?.metered === null
5296
+ ? 'error'
5297
+ : data?.status
5298
+ ),
5198
5299
  // Step types name the CAPABILITY, not this vendor. A second store platform
5199
5300
  // implements the same four commerce steps, and the connection on the step
5200
5301
  // says which store it runs against — so a merchant sees one "Create
@@ -5326,8 +5427,9 @@ var shopify = {
5326
5427
  ? [
5327
5428
  {
5328
5429
  action : 'billing',
5329
- message : 'Approve the Drawbridge plan to activate this connection. You\'ll confirm the pricing on Shopify, then everything else happens here.',
5330
- title : 'Approve your plan'
5430
+ message : 'Approve the Drawbridge plan to activate this connection. You\'ll be asked to select a plan on Shopify, then everything else happens here.',
5431
+ title : 'Approve your plan',
5432
+ type : 'warning'
5331
5433
  }
5332
5434
  ]
5333
5435
  : []
@@ -5343,8 +5445,9 @@ var shopify = {
5343
5445
  ? [
5344
5446
  {
5345
5447
  action : 'billing',
5346
- message : 'Order billing isn\'t set up for this store, so campaign-attributed orders aren\'t being charged. Approve the updated plan to fix it one click here, one confirmation on Shopify.',
5347
- title : 'Fix order billing'
5448
+ message : 'Order billing isn\'t set up for this store, so campaign-attributed orders aren\'t being charged. You\'ll need to reselect your plan on Shopify and approve it to fix this.',
5449
+ title : 'Fix order billing',
5450
+ type : 'error'
5348
5451
  }
5349
5452
  ]
5350
5453
  : []
@@ -6361,6 +6464,11 @@ const publicConnectionKeys = Object.freeze([
6361
6464
  'settings',
6362
6465
  'shop',
6363
6466
  'slug',
6467
+ // Vendor identity plus the metered billing stamp — domain, id, label,
6468
+ // metered — no credentials live here (those are the encrypted settings
6469
+ // blob). The status and tasks hooks pivot on source.metered, so stripping
6470
+ // it here left the presented status blind to the one state it must show.
6471
+ 'source',
6364
6472
  'status',
6365
6473
  'tasks',
6366
6474
  'title',
@@ -7,6 +7,7 @@ import { channels } from '../pricing.js';
7
7
  import { customAlphabet } from 'nanoid';
8
8
  import { toCanonicalEmail } from '../email.js';
9
9
  import { conversionRate } from '../plans.js';
10
+ import { decrypt } from '../encrypt.js';
10
11
  import { slugify } from '../slugify.js';
11
12
  import { safeRequest } from '../safe-http.js';
12
13
  import 'libphonenumber-js';
@@ -19,6 +20,8 @@ import '../index.js';
19
20
  import 'currency-codes';
20
21
  import '../color.js';
21
22
  import 'tinycolor2';
23
+ import 'crypto';
24
+ import '../token.js';
22
25
  import 'slugify';
23
26
  import 'dns';
24
27
  import 'node:http';
@@ -3869,6 +3872,13 @@ const REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1000;
3869
3872
 
3870
3873
  const OAUTH_ERROR_SOURCE = 'oauth';
3871
3874
 
3875
+ // The billing Issue a probed-unmetered store carries in its connection
3876
+ // errors, written and cleared beside the source.metered stamp (health check
3877
+ // and drawbridge-sync's approval restamp share the source tag). One string,
3878
+ // used by both writers, so the entries dedupe and $pull cleanly.
3879
+ const BILLING_ERROR_SOURCE = 'billing';
3880
+ const BILLING_ERROR_MESSAGE = 'Order billing isn\'t set up for this store — reselect and approve the plan from the Shopify connection page.';
3881
+
3872
3882
  // What Shopify says when the merchant has uninstalled or revoked. Neither is
3873
3883
  // retryable and both mean the same thing to a merchant: reconnect.
3874
3884
  const OAUTH_GRANT_REVOKED_CODES = [ 'application_cannot_be_found', 'invalid_grant' ];
@@ -4592,6 +4602,22 @@ var shopify = {
4592
4602
  // roll back.
4593
4603
  const billable = org?.billingProvider === 'shopify' && fee > 0 && ! backfill;
4594
4604
 
4605
+ // THE EFFECTIVE METER HANDLE. The provider row's orderEventHandle
4606
+ // (admin-editable, for the day the plan config's meter changes)
4607
+ // overrides the built-in default; slugified again on read as belt
4608
+ // and braces with the save-side formatter, since a drifted stored
4609
+ // value would mint transactions no meter classifies. Read directly
4610
+ // (not via providerSettings — that module imports this one) and
4611
+ // only for a billable order, which keeps the cost off every
4612
+ // ordinary redemption.
4613
+ const providerRow = billable
4614
+ ? await read.get({ collection : 'provider', query : { slug : 'shopify' } })
4615
+ : null;
4616
+
4617
+ const { orderEventHandle } = providerRow?.settings ? decrypt( providerRow.settings ) : {};
4618
+
4619
+ const handle = ( typeof orderEventHandle === 'string' && slugify( orderEventHandle ) ) || ORDER_EVENT_HANDLE;
4620
+
4595
4621
  // A billable order with no shop id is revenue lost SILENTLY — the
4596
4622
  // gate below just yields no job, the order records normally, and the
4597
4623
  // connection looks healthy. An Error (not a warn) because this is
@@ -4622,7 +4648,7 @@ var shopify = {
4622
4648
  // verify it against the transaction prefix and refuse a
4623
4649
  // drifted pair — idempotency keys are permanent, so a
4624
4650
  // mistraceable event can never be resent under its own key.
4625
- handle : ORDER_EVENT_HANDLE,
4651
+ handle,
4626
4652
  idempotencyKey : String( orderId ),
4627
4653
  orderDocId,
4628
4654
  orderId : String( orderId ),
@@ -4632,7 +4658,7 @@ var shopify = {
4632
4658
  // here — the event handle plus the order id — and sent as the
4633
4659
  // event's `reference`. queue/usage.js stamps the same id onto
4634
4660
  // the order as billed.transaction.
4635
- transaction : ORDER_EVENT_HANDLE + '.' + orderId,
4661
+ transaction : handle + '.' + orderId,
4636
4662
  value : Math.round( fee * 100 )
4637
4663
  },
4638
4664
  name : 'billing',
@@ -4962,6 +4988,63 @@ var shopify = {
4962
4988
 
4963
4989
  }
4964
4990
 
4991
+ // THE ISSUE MARKER rides beside the stamp: a probed-unmetered
4992
+ // store carries a billing entry in the connection's own errors —
4993
+ // the Issues surface a merchant already knows — and a metered
4994
+ // verdict clears it. Keyed on PRESENCE rather than the verdict
4995
+ // changing, so a connection stamped before this writer existed
4996
+ // still gains its entry on the next run. Status is deliberately
4997
+ // untouched on the way in (orders must keep recording), and the
4998
+ // way out mirrors the resume idiom: reactivate only a connection
4999
+ // that is errored with nothing else wrong.
5000
+ const hasBillingIssue = ( connection.errors || [] ).some( ( entry ) => entry.source === BILLING_ERROR_SOURCE );
5001
+
5002
+ if( metered === null && ! hasBillingIssue ){
5003
+
5004
+ writes.push({
5005
+ collection : 'connection',
5006
+ data : {
5007
+ $push : {
5008
+ errors : {
5009
+ message : BILLING_ERROR_MESSAGE,
5010
+ source : BILLING_ERROR_SOURCE
5011
+ }
5012
+ }
5013
+ },
5014
+ operation : 'update',
5015
+ query : { id : connection.id }
5016
+ });
5017
+
5018
+ }
5019
+
5020
+ if( typeof metered === 'string' && hasBillingIssue ){
5021
+
5022
+ writes.push({
5023
+ collection : 'connection',
5024
+ data : {
5025
+ $pull : {
5026
+ errors : { source : BILLING_ERROR_SOURCE }
5027
+ }
5028
+ },
5029
+ operation : 'update',
5030
+ query : { id : connection.id }
5031
+ });
5032
+
5033
+ writes.push({
5034
+ collection : 'connection',
5035
+ data : {
5036
+ $set : { status : 'active' }
5037
+ },
5038
+ operation : 'update',
5039
+ query : {
5040
+ id : connection.id,
5041
+ status : 'error',
5042
+ 'errors.0' : { $exists : false }
5043
+ }
5044
+ });
5045
+
5046
+ }
5047
+
4965
5048
  return {
4966
5049
  writes,
4967
5050
  enqueues : [ {
@@ -5174,7 +5257,15 @@ var shopify = {
5174
5257
  { input : 'text', key : 'apiKey', credential : 'SHOPIFY_API_KEY', label : 'API key', required : true },
5175
5258
  { input : 'password', key : 'apiSecret', credential : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
5176
5259
  { input : 'text', key : 'appHandle', credential : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
5177
- { input : 'text', key : 'listingUrl', credential : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true }
5260
+ { input : 'text', key : 'listingUrl', credential : 'SHOPIFY_APP_LISTING_URL', label : 'App listing URL', required : true },
5261
+ // The usage meter's event handle, when the plan config's meter ever
5262
+ // changes: stored value overrides the built-in default
5263
+ // (events.order.handle) at the mint point, and `format : 'slug'`
5264
+ // normalizes it on save — a handle can carry no spaces or capitals,
5265
+ // and classification against it is case-sensitive. Optional: empty
5266
+ // means the default, and the sender still refuses any handle that
5267
+ // disagrees with the transaction it minted.
5268
+ { format : 'slug', input : 'text', key : 'orderEventHandle', label : 'Order event handle', required : false, setting : true }
5178
5269
  ]
5179
5270
  },
5180
5271
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -5192,9 +5283,19 @@ var shopify = {
5192
5283
  // there is nothing further to choose. `shop` absent means the install did not
5193
5284
  // finish, which is a credential problem rather than a setup one, so the
5194
5285
  // stored status already says so.
5195
- // Nothing to add — no setting can make this connection unusable, so the
5196
- // credential's own verdict stands.
5197
- status : ( data ) => data?.status,
5286
+ //
5287
+ // ONE presentation downgrade: an ACTIVE store whose approval carries no
5288
+ // usage meter (source.metered explicitly null) PRESENTS as error, so the
5289
+ // connections list shows the red state and the merchant knows to click in
5290
+ // — where the Fix-order-billing card and the billing Issue explain the
5291
+ // click. The STORED status stays active on purpose: serving and tracking
5292
+ // gate on it, and the orders recorded while the merchant fixes the plan
5293
+ // are the ones billed afterward.
5294
+ status : ( data ) => (
5295
+ data?.status === 'active' && data?.source?.metered === null
5296
+ ? 'error'
5297
+ : data?.status
5298
+ ),
5198
5299
  // Step types name the CAPABILITY, not this vendor. A second store platform
5199
5300
  // implements the same four commerce steps, and the connection on the step
5200
5301
  // says which store it runs against — so a merchant sees one "Create
@@ -5326,8 +5427,9 @@ var shopify = {
5326
5427
  ? [
5327
5428
  {
5328
5429
  action : 'billing',
5329
- message : 'Approve the Drawbridge plan to activate this connection. You\'ll confirm the pricing on Shopify, then everything else happens here.',
5330
- title : 'Approve your plan'
5430
+ message : 'Approve the Drawbridge plan to activate this connection. You\'ll be asked to select a plan on Shopify, then everything else happens here.',
5431
+ title : 'Approve your plan',
5432
+ type : 'warning'
5331
5433
  }
5332
5434
  ]
5333
5435
  : []
@@ -5343,8 +5445,9 @@ var shopify = {
5343
5445
  ? [
5344
5446
  {
5345
5447
  action : 'billing',
5346
- message : 'Order billing isn\'t set up for this store, so campaign-attributed orders aren\'t being charged. Approve the updated plan to fix it one click here, one confirmation on Shopify.',
5347
- title : 'Fix order billing'
5448
+ message : 'Order billing isn\'t set up for this store, so campaign-attributed orders aren\'t being charged. You\'ll need to reselect your plan on Shopify and approve it to fix this.',
5449
+ title : 'Fix order billing',
5450
+ type : 'error'
5348
5451
  }
5349
5452
  ]
5350
5453
  : []
@@ -6361,6 +6464,11 @@ const publicConnectionKeys = Object.freeze([
6361
6464
  'settings',
6362
6465
  'shop',
6363
6466
  'slug',
6467
+ // Vendor identity plus the metered billing stamp — domain, id, label,
6468
+ // metered — no credentials live here (those are the encrypted settings
6469
+ // blob). The status and tasks hooks pivot on source.metered, so stripping
6470
+ // it here left the presented status blind to the one state it must show.
6471
+ 'source',
6364
6472
  'status',
6365
6473
  'tasks',
6366
6474
  'title',