@drawbridge/drawbridge-utils 0.0.142 → 0.0.144

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",
@@ -4112,7 +4137,7 @@ var shopify_default2 = {
4112
4137
  // against the ones the app now needs, and queues a webhook
4113
4138
  // reconciliation.
4114
4139
  health: async ({ connection: connection2, workflow }, { adminToken, logger: logger2, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {}) => {
4115
- var _a, _b;
4140
+ var _a, _b, _c;
4116
4141
  const request2 = {
4117
4142
  connectionId: workflow.connection,
4118
4143
  organizationId: workflow.organization,
@@ -4134,7 +4159,7 @@ var shopify_default2 = {
4134
4159
  const writes = [];
4135
4160
  let source = connection2.source || null;
4136
4161
  let sourceRepaired = false;
4137
- let metered = (source == null ? void 0 : source.metered) ?? null;
4162
+ let metered;
4138
4163
  let shopCurrency = null;
4139
4164
  let billingProbeFailed = false;
4140
4165
  try {
@@ -4153,25 +4178,65 @@ var shopify_default2 = {
4153
4178
  domain: connection2.shop
4154
4179
  });
4155
4180
  if (subscriptions.length && subscriptions.every((subscription) => typeof subscription.metered === "boolean")) {
4156
- metered = subscriptions.some((subscription) => subscription.metered);
4181
+ metered = ((_a = subscriptions.find((subscription) => subscription.usageLineItemId)) == null ? void 0 : _a.usageLineItemId) || null;
4157
4182
  }
4158
4183
  } catch (probeError) {
4159
4184
  billingProbeFailed = true;
4160
- (_a = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _a.call(logger2, "shopify.health.billing.probe.failed", {
4185
+ (_b = logger2 == null ? void 0 : logger2.warn) == null ? void 0 : _b.call(logger2, "shopify.health.billing.probe.failed", {
4161
4186
  connectionId: connection2.id,
4162
4187
  message: probeError == null ? void 0 : probeError.message,
4163
4188
  shop: connection2.shop
4164
4189
  });
4165
4190
  }
4166
- if (sourceRepaired || metered !== null && metered !== (((_b = connection2.source) == null ? void 0 : _b.metered) ?? null)) {
4191
+ if (sourceRepaired || metered !== void 0 && metered !== (((_c = connection2.source) == null ? void 0 : _c.metered) ?? void 0)) {
4192
+ writes.push({
4193
+ collection: "connection",
4194
+ data: {
4195
+ $set: sourceRepaired ? { source: { ...source, ...metered !== void 0 && { metered } } } : { "source.metered": metered }
4196
+ },
4197
+ operation: "update",
4198
+ query: { id: connection2.id }
4199
+ });
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) {
4167
4218
  writes.push({
4168
4219
  collection: "connection",
4169
4220
  data: {
4170
- $set: sourceRepaired ? { source: { ...source, ...metered !== null && { metered } } } : { "source.metered": metered }
4221
+ $pull: {
4222
+ errors: { source: BILLING_ERROR_SOURCE }
4223
+ }
4171
4224
  },
4172
4225
  operation: "update",
4173
4226
  query: { id: connection2.id }
4174
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
+ });
4175
4240
  }
4176
4241
  return {
4177
4242
  writes,
@@ -4187,11 +4252,12 @@ var shopify_default2 = {
4187
4252
  options: { jobId: "connection.update.register." + workflow.connection + "." + (0, import_node_crypto5.randomUUID)() },
4188
4253
  queue: "connection"
4189
4254
  }],
4190
- message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === false ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
4255
+ message: ((scopesMissing == null ? void 0 : scopesMissing.length) ? "Health check: ping ok, webhooks reconciled \u2014 connection errored, granted scopes are missing: " + scopesMissing.join(", ") + "." : refreshTokenRotated ? "Health check passed \u2014 refresh token rotated, ping ok, webhooks reconciled." : "Health check passed \u2014 ping ok, webhooks reconciled.") + (sourceRepaired ? " Billing source repaired from the live shop." : "") + (metered === null ? " No usage line on the store's approved plan \u2014 order billing needs the merchant to approve the updated plan." : ""),
4191
4256
  request: request2,
4192
4257
  response: {
4193
4258
  billingProbeFailed,
4194
- metered,
4259
+ metered: metered ?? null,
4260
+ meterProbed: metered !== void 0,
4195
4261
  pingedAt: /* @__PURE__ */ new Date(),
4196
4262
  refreshTokenExpiresAt: refreshTokenExpiresAt || null,
4197
4263
  refreshTokenRotated,
@@ -4336,7 +4402,15 @@ var shopify_default2 = {
4336
4402
  { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", required: true },
4337
4403
  { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
4338
4404
  { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
4339
- { 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 }
4340
4414
  ]
4341
4415
  },
4342
4416
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -4478,20 +4552,24 @@ var shopify_default2 = {
4478
4552
  ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4479
4553
  {
4480
4554
  action: "billing",
4481
- message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4482
- title: "Approve your plan"
4555
+ message: "Approve the Drawbridge plan to activate this connection. You'll be asked to select a plan on Shopify, then everything else happens here.",
4556
+ title: "Approve your plan",
4557
+ type: "warning"
4483
4558
  }
4484
4559
  ] : [],
4485
4560
  // The store's ACTIVE approval carries no usage component — stamped by
4486
- // the daily health check and the approval webhook (`source.metered`).
4487
- // Approved pricing is never retroactive, so the fix is a fresh approval.
4488
- // Gated on an EXPLICIT false a connection the probe hasn't reached
4489
- // (or couldn't read) shows nothing rather than nagging on silence.
4490
- ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === false ? [
4561
+ // the daily health check and the approval webhook (`source.metered`:
4562
+ // the usage line's id when present, null when probed-and-absent).
4563
+ // Approved pricing is never retroactive, so the fix is a fresh
4564
+ // approval. Gated on an EXPLICIT null a connection the probe hasn't
4565
+ // reached (or couldn't read) has no key and shows nothing rather than
4566
+ // nagging on silence.
4567
+ ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === null ? [
4491
4568
  {
4492
4569
  action: "billing",
4493
- 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.",
4494
- title: "Fix order billing"
4570
+ 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.",
4571
+ title: "Fix order billing",
4572
+ type: "error"
4495
4573
  }
4496
4574
  ] : []
4497
4575
  ];
@@ -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',
@@ -4866,14 +4892,18 @@ var shopify = {
4866
4892
  // holds a live admin token, so a missing source is one lookup away
4867
4893
  // from fixed.
4868
4894
  //
4869
- // `metered` rides on the same pass: whether the merchant's ACTIVE
4870
- // approval carries a usage line item. A meter added to the app's
4871
- // pricing config is never retroactive it bills only from the
4872
- // moment the merchant approves the plan version that carries it —
4873
- // so an approval with no usage line means every order's usage event
4874
- // is 202-accepted and silently never billed. Stamped on the
4875
- // connection so tasks() can tell the merchant to re-approve, and
4876
- // probed every run so a re-approval clears the card within a day.
4895
+ // `metered` rides on the same pass: the usage LINE ITEM ID from the
4896
+ // merchant's ACTIVE approval an id is proof of WHICH approval
4897
+ // carries the meter, where a boolean was only an assertion. Three
4898
+ // states: a gid string (probed, usage line present), null (probed
4899
+ // and absent the unbillable state tasks() nags on), or no key at
4900
+ // all (never probed silence, never treated as evidence). A meter
4901
+ // added to the app's pricing config is never retroactive — it
4902
+ // bills only from the moment the merchant approves the plan
4903
+ // version that carries it — so a null here means every order's
4904
+ // usage event is 202-accepted and silently never billed. Probed
4905
+ // every run, so a re-approval both clears the card within a day
4906
+ // and refreshes the id it re-minted.
4877
4907
  //
4878
4908
  // Best-effort by design: the ping above already proved access, so a
4879
4909
  // blip in either lookup must not error a healthy connection — the
@@ -4886,7 +4916,9 @@ var shopify = {
4886
4916
 
4887
4917
  let source = connection.source || null;
4888
4918
  let sourceRepaired = false;
4889
- let metered = source?.metered ?? null;
4919
+ // undefined = no verdict this run; a string/null verdict comes
4920
+ // only from a successful probe of an ACTIVE approval.
4921
+ let metered;
4890
4922
  let shopCurrency = null;
4891
4923
  let billingProbeFailed = false;
4892
4924
 
@@ -4913,14 +4945,16 @@ var shopify = {
4913
4945
 
4914
4946
  // Only an ACTIVE approval says anything about metering — no
4915
4947
  // subscriptions (mid-install, plan pending) leaves the stamp
4916
- // untouched rather than asserting false from silence. The
4948
+ // untouched rather than asserting null from silence. The
4917
4949
  // every-boolean guard is version skew insurance: an older
4918
- // @drawbridge/shopify returns subscriptions without the flag,
4919
- // and reading that silence as false would nag every healthy
4920
- // merchant to re-approve a plan that is fine.
4950
+ // @drawbridge/shopify returns subscriptions without the flag
4951
+ // (and without usageLineItemId), and reading that silence as
4952
+ // unmetered would nag every healthy merchant to re-approve a
4953
+ // plan that is fine — the flag being boolean proves an SDK
4954
+ // that also reports the line id.
4921
4955
  if( subscriptions.length && subscriptions.every( ( subscription ) => typeof subscription.metered === 'boolean' ) ){
4922
4956
 
4923
- metered = subscriptions.some( ( subscription ) => subscription.metered );
4957
+ metered = subscriptions.find( ( subscription ) => subscription.usageLineItemId )?.usageLineItemId || null;
4924
4958
 
4925
4959
  }
4926
4960
 
@@ -4936,13 +4970,16 @@ var shopify = {
4936
4970
 
4937
4971
  }
4938
4972
 
4939
- if( sourceRepaired || ( metered !== null && metered !== ( connection.source?.metered ?? null ) ) ){
4973
+ // A verdict writes only when it CHANGES the stored one the daily
4974
+ // run stays idempotent, and undefined (no verdict) never touches
4975
+ // the stamp.
4976
+ if( sourceRepaired || ( metered !== undefined && metered !== ( connection.source?.metered ?? undefined ) ) ){
4940
4977
 
4941
4978
  writes.push({
4942
4979
  collection : 'connection',
4943
4980
  data : {
4944
4981
  $set : sourceRepaired
4945
- ? { source : { ...source, ...( metered !== null && { metered }) } }
4982
+ ? { source : { ...source, ...( metered !== undefined && { metered }) } }
4946
4983
  : { 'source.metered' : metered }
4947
4984
  },
4948
4985
  operation : 'update',
@@ -4951,6 +4988,63 @@ var shopify = {
4951
4988
 
4952
4989
  }
4953
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
+
4954
5048
  return {
4955
5049
  writes,
4956
5050
  enqueues : [ {
@@ -4972,11 +5066,12 @@ var shopify = {
4972
5066
  : 'Health check passed — ping ok, webhooks reconciled.'
4973
5067
  )
4974
5068
  + ( sourceRepaired ? ' Billing source repaired from the live shop.' : '' )
4975
- + ( metered === false ? ' No usage line on the store\'s approved plan — order billing needs the merchant to approve the updated plan.' : '' ),
5069
+ + ( metered === null ? ' No usage line on the store\'s approved plan — order billing needs the merchant to approve the updated plan.' : '' ),
4976
5070
  request,
4977
5071
  response : {
4978
5072
  billingProbeFailed,
4979
- metered,
5073
+ metered : metered ?? null,
5074
+ meterProbed : metered !== undefined,
4980
5075
  pingedAt : new Date(),
4981
5076
  refreshTokenExpiresAt : refreshTokenExpiresAt || null,
4982
5077
  refreshTokenRotated,
@@ -5162,7 +5257,15 @@ var shopify = {
5162
5257
  { input : 'text', key : 'apiKey', credential : 'SHOPIFY_API_KEY', label : 'API key', required : true },
5163
5258
  { input : 'password', key : 'apiSecret', credential : 'SHOPIFY_API_SECRET', label : 'API secret', redact : true, required : true },
5164
5259
  { input : 'text', key : 'appHandle', credential : 'SHOPIFY_APP_HANDLE', label : 'App handle', required : true },
5165
- { 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 }
5166
5269
  ]
5167
5270
  },
5168
5271
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -5314,23 +5417,27 @@ var shopify = {
5314
5417
  ? [
5315
5418
  {
5316
5419
  action : 'billing',
5317
- message : 'Approve the Drawbridge plan to activate this connection. You\'ll confirm the pricing on Shopify, then everything else happens here.',
5318
- title : 'Approve your plan'
5420
+ message : 'Approve the Drawbridge plan to activate this connection. You\'ll be asked to select a plan on Shopify, then everything else happens here.',
5421
+ title : 'Approve your plan',
5422
+ type : 'warning'
5319
5423
  }
5320
5424
  ]
5321
5425
  : []
5322
5426
  ),
5323
5427
  // The store's ACTIVE approval carries no usage component — stamped by
5324
- // the daily health check and the approval webhook (`source.metered`).
5325
- // Approved pricing is never retroactive, so the fix is a fresh approval.
5326
- // Gated on an EXPLICIT false a connection the probe hasn't reached
5327
- // (or couldn't read) shows nothing rather than nagging on silence.
5328
- ...( data?.status === 'active' && data?.source?.metered === false
5428
+ // the daily health check and the approval webhook (`source.metered`:
5429
+ // the usage line's id when present, null when probed-and-absent).
5430
+ // Approved pricing is never retroactive, so the fix is a fresh
5431
+ // approval. Gated on an EXPLICIT null a connection the probe hasn't
5432
+ // reached (or couldn't read) has no key and shows nothing rather than
5433
+ // nagging on silence.
5434
+ ...( data?.status === 'active' && data?.source?.metered === null
5329
5435
  ? [
5330
5436
  {
5331
5437
  action : 'billing',
5332
- 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.',
5333
- title : 'Fix order billing'
5438
+ 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.',
5439
+ title : 'Fix order billing',
5440
+ type : 'error'
5334
5441
  }
5335
5442
  ]
5336
5443
  : []