@drawbridge/drawbridge-utils 0.0.143 → 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",
@@ -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
@@ -4479,8 +4552,9 @@ var shopify_default2 = {
4479
4552
  ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4480
4553
  {
4481
4554
  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"
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"
4484
4558
  }
4485
4559
  ] : [],
4486
4560
  // The store's ACTIVE approval carries no usage component — stamped by
@@ -4493,8 +4567,9 @@ var shopify_default2 = {
4493
4567
  ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === null ? [
4494
4568
  {
4495
4569
  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"
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"
4498
4573
  }
4499
4574
  ] : []
4500
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',
@@ -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
@@ -5326,8 +5417,9 @@ var shopify = {
5326
5417
  ? [
5327
5418
  {
5328
5419
  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'
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'
5331
5423
  }
5332
5424
  ]
5333
5425
  : []
@@ -5343,8 +5435,9 @@ var shopify = {
5343
5435
  ? [
5344
5436
  {
5345
5437
  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'
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'
5348
5441
  }
5349
5442
  ]
5350
5443
  : []
@@ -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
@@ -5326,8 +5417,9 @@ var shopify = {
5326
5417
  ? [
5327
5418
  {
5328
5419
  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'
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'
5331
5423
  }
5332
5424
  ]
5333
5425
  : []
@@ -5343,8 +5435,9 @@ var shopify = {
5343
5435
  ? [
5344
5436
  {
5345
5437
  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'
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'
5348
5441
  }
5349
5442
  ]
5350
5443
  : []
@@ -3292,6 +3292,26 @@ var toCanonicalEmail = (value) => {
3292
3292
  return local + "@" + domain;
3293
3293
  };
3294
3294
 
3295
+ // lib/encrypt.js
3296
+ import crypto from "crypto";
3297
+ var ALGORITHM = "aes-256-gcm";
3298
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
3299
+ var decrypt = (value) => {
3300
+ if (typeof value !== "string") return value;
3301
+ const [ivHex, tagHex, dataHex] = value.split(":");
3302
+ const decipher = crypto.createDecipheriv(
3303
+ ALGORITHM,
3304
+ getKey(),
3305
+ Buffer.from(ivHex, "hex")
3306
+ );
3307
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
3308
+ const result = Buffer.concat([
3309
+ decipher.update(Buffer.from(dataHex, "hex")),
3310
+ decipher.final()
3311
+ ]);
3312
+ return JSON.parse(result.toString("utf8"));
3313
+ };
3314
+
3295
3315
  // lib/slugify.js
3296
3316
  import slug from "slugify";
3297
3317
  var slugify = (value, nochars = false) => {
@@ -3346,6 +3366,8 @@ var generateDiscountCode = customAlphabet2("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
3346
3366
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3347
3367
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3348
3368
  var OAUTH_ERROR_SOURCE = "oauth";
3369
+ var BILLING_ERROR_SOURCE = "billing";
3370
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
3349
3371
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
3350
3372
  var inbound = {
3351
3373
  headers: {
@@ -3866,6 +3888,9 @@ var shopify_default2 = {
3866
3888
  }
3867
3889
  }
3868
3890
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3891
+ const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
3892
+ const { orderEventHandle } = (providerRow == null ? void 0 : providerRow.settings) ? decrypt(providerRow.settings) : {};
3893
+ const handle = typeof orderEventHandle === "string" && slugify(orderEventHandle) || ORDER_EVENT_HANDLE;
3869
3894
  if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3870
3895
  (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3871
3896
  "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"
@@ -3884,7 +3909,7 @@ var shopify_default2 = {
3884
3909
  // verify it against the transaction prefix and refuse a
3885
3910
  // drifted pair — idempotency keys are permanent, so a
3886
3911
  // mistraceable event can never be resent under its own key.
3887
- handle: ORDER_EVENT_HANDLE,
3912
+ handle,
3888
3913
  idempotencyKey: String(orderId),
3889
3914
  orderDocId,
3890
3915
  orderId: String(orderId),
@@ -3894,7 +3919,7 @@ var shopify_default2 = {
3894
3919
  // here — the event handle plus the order id — and sent as the
3895
3920
  // event's `reference`. queue/usage.js stamps the same id onto
3896
3921
  // the order as billed.transaction.
3897
- transaction: ORDER_EVENT_HANDLE + "." + orderId,
3922
+ transaction: handle + "." + orderId,
3898
3923
  value: Math.round(fee * 100)
3899
3924
  },
3900
3925
  name: "billing",
@@ -4099,6 +4124,46 @@ var shopify_default2 = {
4099
4124
  query: { id: connection2.id }
4100
4125
  });
4101
4126
  }
4127
+ const hasBillingIssue = (connection2.errors || []).some((entry) => entry.source === BILLING_ERROR_SOURCE);
4128
+ if (metered === null && !hasBillingIssue) {
4129
+ writes.push({
4130
+ collection: "connection",
4131
+ data: {
4132
+ $push: {
4133
+ errors: {
4134
+ message: BILLING_ERROR_MESSAGE,
4135
+ source: BILLING_ERROR_SOURCE
4136
+ }
4137
+ }
4138
+ },
4139
+ operation: "update",
4140
+ query: { id: connection2.id }
4141
+ });
4142
+ }
4143
+ if (typeof metered === "string" && hasBillingIssue) {
4144
+ writes.push({
4145
+ collection: "connection",
4146
+ data: {
4147
+ $pull: {
4148
+ errors: { source: BILLING_ERROR_SOURCE }
4149
+ }
4150
+ },
4151
+ operation: "update",
4152
+ query: { id: connection2.id }
4153
+ });
4154
+ writes.push({
4155
+ collection: "connection",
4156
+ data: {
4157
+ $set: { status: "active" }
4158
+ },
4159
+ operation: "update",
4160
+ query: {
4161
+ id: connection2.id,
4162
+ status: "error",
4163
+ "errors.0": { $exists: false }
4164
+ }
4165
+ });
4166
+ }
4102
4167
  return {
4103
4168
  writes,
4104
4169
  enqueues: [{
@@ -4263,7 +4328,15 @@ var shopify_default2 = {
4263
4328
  { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", required: true },
4264
4329
  { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
4265
4330
  { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
4266
- { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
4331
+ { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true },
4332
+ // The usage meter's event handle, when the plan config's meter ever
4333
+ // changes: stored value overrides the built-in default
4334
+ // (events.order.handle) at the mint point, and `format : 'slug'`
4335
+ // normalizes it on save — a handle can carry no spaces or capitals,
4336
+ // and classification against it is case-sensitive. Optional: empty
4337
+ // means the default, and the sender still refuses any handle that
4338
+ // disagrees with the transaction it minted.
4339
+ { format: "slug", input: "text", key: "orderEventHandle", label: "Order event handle", required: false, setting: true }
4267
4340
  ]
4268
4341
  },
4269
4342
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -4405,8 +4478,9 @@ var shopify_default2 = {
4405
4478
  ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4406
4479
  {
4407
4480
  action: "billing",
4408
- message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4409
- title: "Approve your plan"
4481
+ message: "Approve the Drawbridge plan to activate this connection. You'll be asked to select a plan on Shopify, then everything else happens here.",
4482
+ title: "Approve your plan",
4483
+ type: "warning"
4410
4484
  }
4411
4485
  ] : [],
4412
4486
  // The store's ACTIVE approval carries no usage component — stamped by
@@ -4419,8 +4493,9 @@ var shopify_default2 = {
4419
4493
  ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === null ? [
4420
4494
  {
4421
4495
  action: "billing",
4422
- 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.",
4423
- title: "Fix order billing"
4496
+ 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.",
4497
+ title: "Fix order billing",
4498
+ type: "error"
4424
4499
  }
4425
4500
  ] : []
4426
4501
  ];
@@ -4429,7 +4504,7 @@ var shopify_default2 = {
4429
4504
  };
4430
4505
 
4431
4506
  // lib/connections/providers/webhook.js
4432
- import crypto from "crypto";
4507
+ import crypto2 from "crypto";
4433
4508
 
4434
4509
  // lib/safe-http.js
4435
4510
  import dns2 from "dns";
@@ -4664,7 +4739,7 @@ var webhook_default = {
4664
4739
  request2.body = body;
4665
4740
  const outgoing = { ...headers };
4666
4741
  if (settings == null ? void 0 : settings.secret) {
4667
- outgoing["X-Drawbridge-Signature"] = "sha256=" + crypto.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
4742
+ outgoing["X-Drawbridge-Signature"] = "sha256=" + crypto2.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
4668
4743
  }
4669
4744
  const response = await send2({ body, headers: outgoing, method, url });
4670
4745
  return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
@@ -3272,6 +3272,45 @@ var toCanonicalEmail = (value) => {
3272
3272
  return local + "@" + domain;
3273
3273
  };
3274
3274
 
3275
+ // lib/encrypt.js
3276
+ var import_crypto2 = __toESM(require("crypto"), 1);
3277
+
3278
+ // lib/token.js
3279
+ var import_crypto = __toESM(require("crypto"), 1);
3280
+ var generate = (bytes = 32, encoding = "base64url") => {
3281
+ const buf = import_crypto.default.randomBytes(bytes);
3282
+ return encoding ? buf.toString(encoding) : buf;
3283
+ };
3284
+
3285
+ // lib/encrypt.js
3286
+ var ALGORITHM = "aes-256-gcm";
3287
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
3288
+ var encrypt = (value) => {
3289
+ const iv = generate(12, null);
3290
+ const cipher = import_crypto2.default.createCipheriv(ALGORITHM, getKey(), iv);
3291
+ const data2 = Buffer.concat([
3292
+ cipher.update(JSON.stringify(value), "utf8"),
3293
+ cipher.final()
3294
+ ]);
3295
+ const tag = cipher.getAuthTag();
3296
+ return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
3297
+ };
3298
+ var decrypt = (value) => {
3299
+ if (typeof value !== "string") return value;
3300
+ const [ivHex, tagHex, dataHex] = value.split(":");
3301
+ const decipher = import_crypto2.default.createDecipheriv(
3302
+ ALGORITHM,
3303
+ getKey(),
3304
+ Buffer.from(ivHex, "hex")
3305
+ );
3306
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
3307
+ const result = Buffer.concat([
3308
+ decipher.update(Buffer.from(dataHex, "hex")),
3309
+ decipher.final()
3310
+ ]);
3311
+ return JSON.parse(result.toString("utf8"));
3312
+ };
3313
+
3275
3314
  // lib/slugify.js
3276
3315
  var import_slugify = __toESM(require("slugify"), 1);
3277
3316
  var slugify = (value, nochars = false) => {
@@ -3326,6 +3365,8 @@ var generateDiscountCode = (0, import_nanoid2.customAlphabet)("0123456789ABCDEFG
3326
3365
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3327
3366
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3328
3367
  var OAUTH_ERROR_SOURCE = "oauth";
3368
+ var BILLING_ERROR_SOURCE = "billing";
3369
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
3329
3370
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
3330
3371
  var inbound = {
3331
3372
  headers: {
@@ -3846,6 +3887,9 @@ var shopify_default2 = {
3846
3887
  }
3847
3888
  }
3848
3889
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3890
+ const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
3891
+ const { orderEventHandle } = (providerRow == null ? void 0 : providerRow.settings) ? decrypt(providerRow.settings) : {};
3892
+ const handle = typeof orderEventHandle === "string" && slugify(orderEventHandle) || ORDER_EVENT_HANDLE;
3849
3893
  if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3850
3894
  (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3851
3895
  "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"
@@ -3864,7 +3908,7 @@ var shopify_default2 = {
3864
3908
  // verify it against the transaction prefix and refuse a
3865
3909
  // drifted pair — idempotency keys are permanent, so a
3866
3910
  // mistraceable event can never be resent under its own key.
3867
- handle: ORDER_EVENT_HANDLE,
3911
+ handle,
3868
3912
  idempotencyKey: String(orderId),
3869
3913
  orderDocId,
3870
3914
  orderId: String(orderId),
@@ -3874,7 +3918,7 @@ var shopify_default2 = {
3874
3918
  // here — the event handle plus the order id — and sent as the
3875
3919
  // event's `reference`. queue/usage.js stamps the same id onto
3876
3920
  // the order as billed.transaction.
3877
- transaction: ORDER_EVENT_HANDLE + "." + orderId,
3921
+ transaction: handle + "." + orderId,
3878
3922
  value: Math.round(fee * 100)
3879
3923
  },
3880
3924
  name: "billing",
@@ -4079,6 +4123,46 @@ var shopify_default2 = {
4079
4123
  query: { id: connection2.id }
4080
4124
  });
4081
4125
  }
4126
+ const hasBillingIssue = (connection2.errors || []).some((entry) => entry.source === BILLING_ERROR_SOURCE);
4127
+ if (metered === null && !hasBillingIssue) {
4128
+ writes.push({
4129
+ collection: "connection",
4130
+ data: {
4131
+ $push: {
4132
+ errors: {
4133
+ message: BILLING_ERROR_MESSAGE,
4134
+ source: BILLING_ERROR_SOURCE
4135
+ }
4136
+ }
4137
+ },
4138
+ operation: "update",
4139
+ query: { id: connection2.id }
4140
+ });
4141
+ }
4142
+ if (typeof metered === "string" && hasBillingIssue) {
4143
+ writes.push({
4144
+ collection: "connection",
4145
+ data: {
4146
+ $pull: {
4147
+ errors: { source: BILLING_ERROR_SOURCE }
4148
+ }
4149
+ },
4150
+ operation: "update",
4151
+ query: { id: connection2.id }
4152
+ });
4153
+ writes.push({
4154
+ collection: "connection",
4155
+ data: {
4156
+ $set: { status: "active" }
4157
+ },
4158
+ operation: "update",
4159
+ query: {
4160
+ id: connection2.id,
4161
+ status: "error",
4162
+ "errors.0": { $exists: false }
4163
+ }
4164
+ });
4165
+ }
4082
4166
  return {
4083
4167
  writes,
4084
4168
  enqueues: [{
@@ -4243,7 +4327,15 @@ var shopify_default2 = {
4243
4327
  { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", required: true },
4244
4328
  { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
4245
4329
  { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
4246
- { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
4330
+ { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true },
4331
+ // The usage meter's event handle, when the plan config's meter ever
4332
+ // changes: stored value overrides the built-in default
4333
+ // (events.order.handle) at the mint point, and `format : 'slug'`
4334
+ // normalizes it on save — a handle can carry no spaces or capitals,
4335
+ // and classification against it is case-sensitive. Optional: empty
4336
+ // means the default, and the sender still refuses any handle that
4337
+ // disagrees with the transaction it minted.
4338
+ { format: "slug", input: "text", key: "orderEventHandle", label: "Order event handle", required: false, setting: true }
4247
4339
  ]
4248
4340
  },
4249
4341
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -4385,8 +4477,9 @@ var shopify_default2 = {
4385
4477
  ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4386
4478
  {
4387
4479
  action: "billing",
4388
- message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4389
- title: "Approve your plan"
4480
+ message: "Approve the Drawbridge plan to activate this connection. You'll be asked to select a plan on Shopify, then everything else happens here.",
4481
+ title: "Approve your plan",
4482
+ type: "warning"
4390
4483
  }
4391
4484
  ] : [],
4392
4485
  // The store's ACTIVE approval carries no usage component — stamped by
@@ -4399,8 +4492,9 @@ var shopify_default2 = {
4399
4492
  ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === null ? [
4400
4493
  {
4401
4494
  action: "billing",
4402
- 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.",
4403
- title: "Fix order billing"
4495
+ 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.",
4496
+ title: "Fix order billing",
4497
+ type: "error"
4404
4498
  }
4405
4499
  ] : []
4406
4500
  ];
@@ -4930,45 +5024,6 @@ var publicConnectionKeys = Object.freeze([
4930
5024
  "warnings"
4931
5025
  ]);
4932
5026
 
4933
- // lib/encrypt.js
4934
- var import_crypto2 = __toESM(require("crypto"), 1);
4935
-
4936
- // lib/token.js
4937
- var import_crypto = __toESM(require("crypto"), 1);
4938
- var generate = (bytes = 32, encoding = "base64url") => {
4939
- const buf = import_crypto.default.randomBytes(bytes);
4940
- return encoding ? buf.toString(encoding) : buf;
4941
- };
4942
-
4943
- // lib/encrypt.js
4944
- var ALGORITHM = "aes-256-gcm";
4945
- var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
4946
- var encrypt = (value) => {
4947
- const iv = generate(12, null);
4948
- const cipher = import_crypto2.default.createCipheriv(ALGORITHM, getKey(), iv);
4949
- const data2 = Buffer.concat([
4950
- cipher.update(JSON.stringify(value), "utf8"),
4951
- cipher.final()
4952
- ]);
4953
- const tag = cipher.getAuthTag();
4954
- return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
4955
- };
4956
- var decrypt = (value) => {
4957
- if (typeof value !== "string") return value;
4958
- const [ivHex, tagHex, dataHex] = value.split(":");
4959
- const decipher = import_crypto2.default.createDecipheriv(
4960
- ALGORITHM,
4961
- getKey(),
4962
- Buffer.from(ivHex, "hex")
4963
- );
4964
- decipher.setAuthTag(Buffer.from(tagHex, "hex"));
4965
- const result = Buffer.concat([
4966
- decipher.update(Buffer.from(dataHex, "hex")),
4967
- decipher.final()
4968
- ]);
4969
- return JSON.parse(result.toString("utf8"));
4970
- };
4971
-
4972
5027
  // lib/providers.js
4973
5028
  var providerFields = (slug2) => {
4974
5029
  var _a;
@@ -5008,9 +5063,10 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
5008
5063
  });
5009
5064
  const stored = (existing == null ? void 0 : existing.settings) ? decrypt(existing.settings) : {};
5010
5065
  const pasted = (value) => typeof value === "string" ? value.trim().replace(/^['"]+|['"]+$/g, "") : value;
5066
+ const normalized = (field2, value) => field2.format === "slug" && typeof value === "string" && value ? slugify(value) : value;
5011
5067
  const merged = mergeSettings({
5012
5068
  existing: stored,
5013
- incoming: Object.fromEntries(fields2.map((field2) => [field2.key, pasted(settings == null ? void 0 : settings[field2.key])]))
5069
+ incoming: Object.fromEntries(fields2.map((field2) => [field2.key, normalized(field2, pasted(settings == null ? void 0 : settings[field2.key]))]))
5014
5070
  });
5015
5071
  for (const key of Array.isArray(clear) ? clear : []) {
5016
5072
  if (fields2.some((field2) => field2.key === key)) delete merged[key];
@@ -1,5 +1,6 @@
1
1
  import { connections, mergeSettings } from './connections/index.cjs';
2
2
  import { decrypt, encrypt } from './encrypt.cjs';
3
+ import { slugify } from './slugify.cjs';
3
4
  import './connections/oauth.cjs';
4
5
  import 'node:crypto';
5
6
  import './phone.cjs';
@@ -18,8 +19,6 @@ import 'nanoid';
18
19
  import './color.cjs';
19
20
  import 'tinycolor2';
20
21
  import './email.cjs';
21
- import './slugify.cjs';
22
- import 'slugify';
23
22
  import './safe-http.cjs';
24
23
  import 'dns';
25
24
  import 'node:http';
@@ -29,6 +28,7 @@ import 'axios';
29
28
  import 'net';
30
29
  import 'crypto';
31
30
  import './token.cjs';
31
+ import 'slugify';
32
32
 
33
33
  // DRAWBRIDGE'S OWN CREDENTIALS for a vendor, as opposed to a merchant's.
34
34
  //
@@ -202,6 +202,17 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
202
202
  ? value.trim().replace( /^['"]+|['"]+$/g, '' )
203
203
  : value;
204
204
 
205
+ // A field declared `format : 'slug'` is normalized on the way IN — a value
206
+ // like Shopify's meter event handle can legally hold no spaces or capitals,
207
+ // and classification against it is case-sensitive, so the stored copy is
208
+ // forced into the only shape the vendor accepts rather than trusting the
209
+ // admin's typing.
210
+ const normalized = ( field, value ) => (
211
+ field.format === 'slug' && typeof value === 'string' && value
212
+ ? slugify( value )
213
+ : value
214
+ );
215
+
205
216
  // mergeSettings is the blank-keeps rule, already used for merchant
206
217
  // connection settings in route/organization-connection.js — same behaviour,
207
218
  // one implementation. The declared-field allowlist is the part that is ours:
@@ -209,7 +220,7 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
209
220
  // not declare reaching the document.
210
221
  const merged = mergeSettings({
211
222
  existing : stored,
212
- incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, pasted( settings?.[ field.key ] ) ] ) )
223
+ incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, normalized( field, pasted( settings?.[ field.key ] ) ) ] ) )
213
224
  });
214
225
 
215
226
  // CLEARING IS EXPLICIT, because blank already means keep and the two cannot
@@ -1,5 +1,6 @@
1
1
  import { connections, mergeSettings } from './connections/index.js';
2
2
  import { decrypt, encrypt } from './encrypt.js';
3
+ import { slugify } from './slugify.js';
3
4
  import './connections/oauth.js';
4
5
  import 'node:crypto';
5
6
  import './phone.js';
@@ -18,8 +19,6 @@ import 'nanoid';
18
19
  import './color.js';
19
20
  import 'tinycolor2';
20
21
  import './email.js';
21
- import './slugify.js';
22
- import 'slugify';
23
22
  import './safe-http.js';
24
23
  import 'dns';
25
24
  import 'node:http';
@@ -29,6 +28,7 @@ import 'axios';
29
28
  import 'net';
30
29
  import 'crypto';
31
30
  import './token.js';
31
+ import 'slugify';
32
32
 
33
33
  // DRAWBRIDGE'S OWN CREDENTIALS for a vendor, as opposed to a merchant's.
34
34
  //
@@ -202,6 +202,17 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
202
202
  ? value.trim().replace( /^['"]+|['"]+$/g, '' )
203
203
  : value;
204
204
 
205
+ // A field declared `format : 'slug'` is normalized on the way IN — a value
206
+ // like Shopify's meter event handle can legally hold no spaces or capitals,
207
+ // and classification against it is case-sensitive, so the stored copy is
208
+ // forced into the only shape the vendor accepts rather than trusting the
209
+ // admin's typing.
210
+ const normalized = ( field, value ) => (
211
+ field.format === 'slug' && typeof value === 'string' && value
212
+ ? slugify( value )
213
+ : value
214
+ );
215
+
205
216
  // mergeSettings is the blank-keeps rule, already used for merchant
206
217
  // connection settings in route/organization-connection.js — same behaviour,
207
218
  // one implementation. The declared-field allowlist is the part that is ours:
@@ -209,7 +220,7 @@ const saveProviderSettings = async ({ authenticated, clear, controller, settings
209
220
  // not declare reaching the document.
210
221
  const merged = mergeSettings({
211
222
  existing : stored,
212
- incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, pasted( settings?.[ field.key ] ) ] ) )
223
+ incoming : Object.fromEntries( fields.map( ( field ) => [ field.key, normalized( field, pasted( settings?.[ field.key ] ) ) ] ) )
213
224
  });
214
225
 
215
226
  // CLEARING IS EXPLICIT, because blank already means keep and the two cannot
package/dist/providers.js CHANGED
@@ -3228,6 +3228,45 @@ var toCanonicalEmail = (value) => {
3228
3228
  return local + "@" + domain;
3229
3229
  };
3230
3230
 
3231
+ // lib/encrypt.js
3232
+ import crypto2 from "crypto";
3233
+
3234
+ // lib/token.js
3235
+ import crypto from "crypto";
3236
+ var generate = (bytes = 32, encoding = "base64url") => {
3237
+ const buf = crypto.randomBytes(bytes);
3238
+ return encoding ? buf.toString(encoding) : buf;
3239
+ };
3240
+
3241
+ // lib/encrypt.js
3242
+ var ALGORITHM = "aes-256-gcm";
3243
+ var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
3244
+ var encrypt = (value) => {
3245
+ const iv = generate(12, null);
3246
+ const cipher = crypto2.createCipheriv(ALGORITHM, getKey(), iv);
3247
+ const data2 = Buffer.concat([
3248
+ cipher.update(JSON.stringify(value), "utf8"),
3249
+ cipher.final()
3250
+ ]);
3251
+ const tag = cipher.getAuthTag();
3252
+ return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
3253
+ };
3254
+ var decrypt = (value) => {
3255
+ if (typeof value !== "string") return value;
3256
+ const [ivHex, tagHex, dataHex] = value.split(":");
3257
+ const decipher = crypto2.createDecipheriv(
3258
+ ALGORITHM,
3259
+ getKey(),
3260
+ Buffer.from(ivHex, "hex")
3261
+ );
3262
+ decipher.setAuthTag(Buffer.from(tagHex, "hex"));
3263
+ const result = Buffer.concat([
3264
+ decipher.update(Buffer.from(dataHex, "hex")),
3265
+ decipher.final()
3266
+ ]);
3267
+ return JSON.parse(result.toString("utf8"));
3268
+ };
3269
+
3231
3270
  // lib/slugify.js
3232
3271
  import slug from "slugify";
3233
3272
  var slugify = (value, nochars = false) => {
@@ -3282,6 +3321,8 @@ var generateDiscountCode = customAlphabet2("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ
3282
3321
  var ORDER_EVENT_HANDLE = slugify("drawbridge-orders");
3283
3322
  var REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1e3;
3284
3323
  var OAUTH_ERROR_SOURCE = "oauth";
3324
+ var BILLING_ERROR_SOURCE = "billing";
3325
+ var BILLING_ERROR_MESSAGE = "Order billing isn't set up for this store \u2014 reselect and approve the plan from the Shopify connection page.";
3285
3326
  var OAUTH_GRANT_REVOKED_CODES = ["application_cannot_be_found", "invalid_grant"];
3286
3327
  var inbound = {
3287
3328
  headers: {
@@ -3802,6 +3843,9 @@ var shopify_default2 = {
3802
3843
  }
3803
3844
  }
3804
3845
  const billable = (org == null ? void 0 : org.billingProvider) === "shopify" && fee > 0 && !backfill;
3846
+ const providerRow = billable ? await read.get({ collection: "provider", query: { slug: "shopify" } }) : null;
3847
+ const { orderEventHandle } = (providerRow == null ? void 0 : providerRow.settings) ? decrypt(providerRow.settings) : {};
3848
+ const handle = typeof orderEventHandle === "string" && slugify(orderEventHandle) || ORDER_EVENT_HANDLE;
3805
3849
  if (billable && !((_f = connection2 == null ? void 0 : connection2.source) == null ? void 0 : _f.id)) {
3806
3850
  (_g = logger2 == null ? void 0 : logger2.error) == null ? void 0 : _g.call(logger2, new Error(
3807
3851
  "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"
@@ -3820,7 +3864,7 @@ var shopify_default2 = {
3820
3864
  // verify it against the transaction prefix and refuse a
3821
3865
  // drifted pair — idempotency keys are permanent, so a
3822
3866
  // mistraceable event can never be resent under its own key.
3823
- handle: ORDER_EVENT_HANDLE,
3867
+ handle,
3824
3868
  idempotencyKey: String(orderId),
3825
3869
  orderDocId,
3826
3870
  orderId: String(orderId),
@@ -3830,7 +3874,7 @@ var shopify_default2 = {
3830
3874
  // here — the event handle plus the order id — and sent as the
3831
3875
  // event's `reference`. queue/usage.js stamps the same id onto
3832
3876
  // the order as billed.transaction.
3833
- transaction: ORDER_EVENT_HANDLE + "." + orderId,
3877
+ transaction: handle + "." + orderId,
3834
3878
  value: Math.round(fee * 100)
3835
3879
  },
3836
3880
  name: "billing",
@@ -4035,6 +4079,46 @@ var shopify_default2 = {
4035
4079
  query: { id: connection2.id }
4036
4080
  });
4037
4081
  }
4082
+ const hasBillingIssue = (connection2.errors || []).some((entry) => entry.source === BILLING_ERROR_SOURCE);
4083
+ if (metered === null && !hasBillingIssue) {
4084
+ writes.push({
4085
+ collection: "connection",
4086
+ data: {
4087
+ $push: {
4088
+ errors: {
4089
+ message: BILLING_ERROR_MESSAGE,
4090
+ source: BILLING_ERROR_SOURCE
4091
+ }
4092
+ }
4093
+ },
4094
+ operation: "update",
4095
+ query: { id: connection2.id }
4096
+ });
4097
+ }
4098
+ if (typeof metered === "string" && hasBillingIssue) {
4099
+ writes.push({
4100
+ collection: "connection",
4101
+ data: {
4102
+ $pull: {
4103
+ errors: { source: BILLING_ERROR_SOURCE }
4104
+ }
4105
+ },
4106
+ operation: "update",
4107
+ query: { id: connection2.id }
4108
+ });
4109
+ writes.push({
4110
+ collection: "connection",
4111
+ data: {
4112
+ $set: { status: "active" }
4113
+ },
4114
+ operation: "update",
4115
+ query: {
4116
+ id: connection2.id,
4117
+ status: "error",
4118
+ "errors.0": { $exists: false }
4119
+ }
4120
+ });
4121
+ }
4038
4122
  return {
4039
4123
  writes,
4040
4124
  enqueues: [{
@@ -4199,7 +4283,15 @@ var shopify_default2 = {
4199
4283
  { input: "text", key: "apiKey", credential: "SHOPIFY_API_KEY", label: "API key", required: true },
4200
4284
  { input: "password", key: "apiSecret", credential: "SHOPIFY_API_SECRET", label: "API secret", redact: true, required: true },
4201
4285
  { input: "text", key: "appHandle", credential: "SHOPIFY_APP_HANDLE", label: "App handle", required: true },
4202
- { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true }
4286
+ { input: "text", key: "listingUrl", credential: "SHOPIFY_APP_LISTING_URL", label: "App listing URL", required: true },
4287
+ // The usage meter's event handle, when the plan config's meter ever
4288
+ // changes: stored value overrides the built-in default
4289
+ // (events.order.handle) at the mint point, and `format : 'slug'`
4290
+ // normalizes it on save — a handle can carry no spaces or capitals,
4291
+ // and classification against it is case-sensitive. Optional: empty
4292
+ // means the default, and the sender still refuses any handle that
4293
+ // disagrees with the transaction it minted.
4294
+ { format: "slug", input: "text", key: "orderEventHandle", label: "Order event handle", required: false, setting: true }
4203
4295
  ]
4204
4296
  },
4205
4297
  // A pre-launch integration: it only surfaces once the App Store listing
@@ -4341,8 +4433,9 @@ var shopify_default2 = {
4341
4433
  ...(data2 == null ? void 0 : data2.status) === "pending" ? [
4342
4434
  {
4343
4435
  action: "billing",
4344
- message: "Approve the Drawbridge plan to activate this connection. You'll confirm the pricing on Shopify, then everything else happens here.",
4345
- title: "Approve your plan"
4436
+ message: "Approve the Drawbridge plan to activate this connection. You'll be asked to select a plan on Shopify, then everything else happens here.",
4437
+ title: "Approve your plan",
4438
+ type: "warning"
4346
4439
  }
4347
4440
  ] : [],
4348
4441
  // The store's ACTIVE approval carries no usage component — stamped by
@@ -4355,8 +4448,9 @@ var shopify_default2 = {
4355
4448
  ...(data2 == null ? void 0 : data2.status) === "active" && ((_a = data2 == null ? void 0 : data2.source) == null ? void 0 : _a.metered) === null ? [
4356
4449
  {
4357
4450
  action: "billing",
4358
- 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.",
4359
- title: "Fix order billing"
4451
+ 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.",
4452
+ title: "Fix order billing",
4453
+ type: "error"
4360
4454
  }
4361
4455
  ] : []
4362
4456
  ];
@@ -4365,7 +4459,7 @@ var shopify_default2 = {
4365
4459
  };
4366
4460
 
4367
4461
  // lib/connections/providers/webhook.js
4368
- import crypto from "crypto";
4462
+ import crypto3 from "crypto";
4369
4463
 
4370
4464
  // lib/safe-http.js
4371
4465
  import dns2 from "dns";
@@ -4600,7 +4694,7 @@ var webhook_default = {
4600
4694
  request2.body = body;
4601
4695
  const outgoing = { ...headers };
4602
4696
  if (settings == null ? void 0 : settings.secret) {
4603
- outgoing["X-Drawbridge-Signature"] = "sha256=" + crypto.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
4697
+ outgoing["X-Drawbridge-Signature"] = "sha256=" + crypto3.createHmac("sha256", settings.secret).update(JSON.stringify(body)).digest("hex");
4604
4698
  }
4605
4699
  const response = await send2({ body, headers: outgoing, method, url });
4606
4700
  return { message: "Webhook POSTed to " + url + ".", request: request2, response: response || { delivered: true } };
@@ -4886,45 +4980,6 @@ var publicConnectionKeys = Object.freeze([
4886
4980
  "warnings"
4887
4981
  ]);
4888
4982
 
4889
- // lib/encrypt.js
4890
- import crypto3 from "crypto";
4891
-
4892
- // lib/token.js
4893
- import crypto2 from "crypto";
4894
- var generate = (bytes = 32, encoding = "base64url") => {
4895
- const buf = crypto2.randomBytes(bytes);
4896
- return encoding ? buf.toString(encoding) : buf;
4897
- };
4898
-
4899
- // lib/encrypt.js
4900
- var ALGORITHM = "aes-256-gcm";
4901
- var getKey = () => Buffer.from(process.env.ENCRYPT_CONNECTION_SECRET, "hex");
4902
- var encrypt = (value) => {
4903
- const iv = generate(12, null);
4904
- const cipher = crypto3.createCipheriv(ALGORITHM, getKey(), iv);
4905
- const data2 = Buffer.concat([
4906
- cipher.update(JSON.stringify(value), "utf8"),
4907
- cipher.final()
4908
- ]);
4909
- const tag = cipher.getAuthTag();
4910
- return [iv, tag, data2].map((b) => b.toString("hex")).join(":");
4911
- };
4912
- var decrypt = (value) => {
4913
- if (typeof value !== "string") return value;
4914
- const [ivHex, tagHex, dataHex] = value.split(":");
4915
- const decipher = crypto3.createDecipheriv(
4916
- ALGORITHM,
4917
- getKey(),
4918
- Buffer.from(ivHex, "hex")
4919
- );
4920
- decipher.setAuthTag(Buffer.from(tagHex, "hex"));
4921
- const result = Buffer.concat([
4922
- decipher.update(Buffer.from(dataHex, "hex")),
4923
- decipher.final()
4924
- ]);
4925
- return JSON.parse(result.toString("utf8"));
4926
- };
4927
-
4928
4983
  // lib/providers.js
4929
4984
  var providerFields = (slug2) => {
4930
4985
  var _a;
@@ -4964,9 +5019,10 @@ var saveProviderSettings = async ({ authenticated, clear, controller, settings,
4964
5019
  });
4965
5020
  const stored = (existing == null ? void 0 : existing.settings) ? decrypt(existing.settings) : {};
4966
5021
  const pasted = (value) => typeof value === "string" ? value.trim().replace(/^['"]+|['"]+$/g, "") : value;
5022
+ const normalized = (field2, value) => field2.format === "slug" && typeof value === "string" && value ? slugify(value) : value;
4967
5023
  const merged = mergeSettings({
4968
5024
  existing: stored,
4969
- incoming: Object.fromEntries(fields2.map((field2) => [field2.key, pasted(settings == null ? void 0 : settings[field2.key])]))
5025
+ incoming: Object.fromEntries(fields2.map((field2) => [field2.key, normalized(field2, pasted(settings == null ? void 0 : settings[field2.key]))]))
4970
5026
  });
4971
5027
  for (const key of Array.isArray(clear) ? clear : []) {
4972
5028
  if (fields2.some((field2) => field2.key === key)) delete merged[key];
package/package.json CHANGED
@@ -216,5 +216,5 @@
216
216
  "prepublishOnly": ". \"$HOME/.nvm/nvm.sh\" && nvm use && tsup && node --test"
217
217
  },
218
218
  "types": "dist/index.d.ts",
219
- "version": "0.0.143"
219
+ "version": "0.0.144"
220
220
  }