@drawbridge/drawbridge-utils 0.0.136 → 0.0.139

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.
@@ -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 { slugify } from '../slugify.js';
10
11
  import { safeRequest } from '../safe-http.js';
11
12
  import 'libphonenumber-js';
12
13
  import '../billing.js';
@@ -18,6 +19,7 @@ import '../index.js';
18
19
  import 'currency-codes';
19
20
  import '../color.js';
20
21
  import 'tinycolor2';
22
+ import 'slugify';
21
23
  import 'dns';
22
24
  import 'node:http';
23
25
  import 'node:https';
@@ -912,6 +914,9 @@ var attentive = {
912
914
  },
913
915
  // A contact destination, like Klaviyo and Mailchimp — a merchant could
914
916
  // reasonably keep several up to date at once.
917
+ // No vendor-facing event identities — declared empty so the contract stays
918
+ // explicit: a vendor that gains one adds it here, never as a side channel.
919
+ events : {},
915
920
  exclusive : false,
916
921
  feature : 'organization:connection:attentive',
917
922
  fields : [
@@ -1703,6 +1708,9 @@ var drawbridge = {
1703
1708
  'Nothing to do. These steps are available in every workflow builder.'
1704
1709
  ]
1705
1710
  },
1711
+ // No vendor-facing event identities — declared empty so the contract stays
1712
+ // explicit: a vendor that gains one adds it here, never as a side channel.
1713
+ events : {},
1706
1714
  exclusive : false,
1707
1715
  fields : [],
1708
1716
  group : 'developer',
@@ -2750,6 +2758,9 @@ var klaviyo = {
2750
2758
  // current — so the exclusivity that once applied when these were SENDERS is
2751
2759
  // deliberately gone. That was removed once already; declaring it out loud is
2752
2760
  // what stops it coming back by inference.
2761
+ // No vendor-facing event identities — declared empty so the contract stays
2762
+ // explicit: a vendor that gains one adds it here, never as a side channel.
2763
+ events : {},
2753
2764
  exclusive : false,
2754
2765
  feature : 'organization:connection:klaviyo',
2755
2766
  fields : [
@@ -3392,6 +3403,9 @@ var mailchimp = {
3392
3403
  // picking two providers to send the same mail was meaningless. As contact
3393
3404
  // syncs they are destinations, and a merchant could reasonably keep several
3394
3405
  // up to date, so the exclusivity is gone.
3406
+ // No vendor-facing event identities — declared empty so the contract stays
3407
+ // explicit: a vendor that gains one adds it here, never as a side channel.
3408
+ events : {},
3395
3409
  exclusive : false,
3396
3410
  feature : 'organization:connection:mailchimp',
3397
3411
  fields : [
@@ -3831,6 +3845,24 @@ const attributeLineItems = ( lineItems = [] ) => lineItems.reduce(
3831
3845
  // types them into a checkout.
3832
3846
  const generateDiscountCode = customAlphabet( '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8 );
3833
3847
 
3848
+ // THE USAGE METER'S EVENT HANDLE — the string that decides whether an order's
3849
+ // billing event bills or is silently ingested as a plain custom event. It must
3850
+ // equal, case-sensitively, the meter HANDLE configured on the app's plan in
3851
+ // the Dev Dashboard (the plan editor's Handle field — display names prove
3852
+ // nothing) and the SHOPIFY_USAGE_ORDERS_EVENT_HANDLE default in
3853
+ // @drawbridge/shopify, which this package cannot import. This is the single
3854
+ // mint point on the utils side: the transaction id is prefixed with it, the
3855
+ // billing enqueue carries it, and the manifest publishes it under
3856
+ // events.order.handle — so drawbridge-sync can refuse to send an event whose
3857
+ // transaction and handle disagree instead of burning a permanent idempotency
3858
+ // key on a mistraceable event.
3859
+ //
3860
+ // Slugified at definition: a handle can carry no spaces or capitals, and
3861
+ // classification is case-sensitive, so a future edit of the literal is forced
3862
+ // into the only shape a meter handle can legally take before it can mint a
3863
+ // single transaction.
3864
+ const ORDER_EVENT_HANDLE = slugify( 'drawbridge-orders' );
3865
+
3834
3866
  // Rotate before the window closes rather than at it: Shopify's refresh token has
3835
3867
  // an expiry, and a rotation attempted after it has passed cannot succeed.
3836
3868
  const REFRESH_TOKEN_FRESHNESS_BUFFER_MS = 5 * 24 * 60 * 60 * 1000;
@@ -3919,7 +3951,7 @@ var shopify = {
3919
3951
  guide : [
3920
3952
  'Open the Drawbridge listing on the Shopify App Store.',
3921
3953
  'Install the app on the store you want to connect. It opens in Shopify admin and stays there.',
3922
- 'Choose a plan when Shopify asks. The connection shows Pending until you do, then Active.',
3954
+ 'Approve the Drawbridge plan when prompted — during install, or from the connection page here. The connection shows Pending until you do, then Active.',
3923
3955
  'Come back here — the connections list updates on its own once the install lands.'
3924
3956
  ],
3925
3957
  // Names where the link GOES rather than what it does: installing happens on
@@ -3933,6 +3965,29 @@ var shopify = {
3933
3965
  // ONE STORE PER ORGANIZATION. Two Shopify stores on one org would give every
3934
3966
  // attributed order two possible sources.
3935
3967
  exclusive : true,
3968
+ // VENDOR-FACING EVENT IDENTITY, published so readers outside this package
3969
+ // (drawbridge-sync's usage queue, an operator checking the Dev Dashboard)
3970
+ // see the same handle the order hook mints into transactions and enqueues.
3971
+ events : {
3972
+ order : {
3973
+ handle : ORDER_EVENT_HANDLE,
3974
+ // THE SUBSCRIPTION THE APP MINTS FOR ITSELF (Billing API, not the
3975
+ // managed-pricing meter system): a $0 recurring line plus this usage
3976
+ // line, approved by the org owner at Shopify's confirmation screen.
3977
+ // `terms` is the merchant-visible consent copy; "up to" because the
3978
+ // actual rate is plan-derived (conversionRate) and must never exceed
3979
+ // what was approved. cappedAmount bounds one 30-day cycle's usage
3980
+ // charges — a record beyond it errors until the merchant raises it.
3981
+ // ponytail: $1,000 cap ≈ $66k attributed revenue/cycle at 1.5%; raise
3982
+ // via a re-approved subscription when a merchant outgrows it.
3983
+ pricing : {
3984
+ cappedAmount : 1000,
3985
+ currency : 'usd',
3986
+ name : 'Drawbridge Orders',
3987
+ terms : 'Up to 1.5% of revenue from orders attributed to Drawbridge campaigns'
3988
+ }
3989
+ }
3990
+ },
3936
3991
  feature : 'organization:connection:shopify',
3937
3992
  fields : [
3938
3993
  {
@@ -4535,9 +4590,39 @@ var shopify = {
4535
4590
  // was charged when the order was first recorded. Enqueues run after
4536
4591
  // the transaction commits, so the job can never observe rows that
4537
4592
  // roll back.
4538
- const enqueues = ( org?.billingProvider === 'shopify' && fee > 0 && connection?.source?.id && ! backfill )
4593
+ const billable = org?.billingProvider === 'shopify' && fee > 0 && ! backfill;
4594
+
4595
+ // A billable order with no shop id is revenue lost SILENTLY — the
4596
+ // gate below just yields no job, the order records normally, and the
4597
+ // connection looks healthy. An Error (not a warn) because this is
4598
+ // money: the order id goes in the MESSAGE, since Sentry matches
4599
+ // titles, and the health check's source self-heal is what makes the
4600
+ // state rare enough to page on.
4601
+ if( billable && ! connection?.source?.id ){
4602
+
4603
+ logger?.error?.( new Error(
4604
+ 'shopify.usage.billing.skipped on order ' + orderId + ': '
4605
+ + Math.round( fee * 100 ) + ' cents not billed — connection '
4606
+ + connection?.id + ' has no source.id'
4607
+ ), {
4608
+ extra : {
4609
+ connectionId : connection?.id,
4610
+ fee,
4611
+ orderDocId,
4612
+ orderId : String( orderId )
4613
+ }
4614
+ });
4615
+
4616
+ }
4617
+
4618
+ const enqueues = ( billable && connection?.source?.id )
4539
4619
  ? [ {
4540
4620
  data : {
4621
+ // The meter handle travels WITH the job so the sender can
4622
+ // verify it against the transaction prefix and refuse a
4623
+ // drifted pair — idempotency keys are permanent, so a
4624
+ // mistraceable event can never be resent under its own key.
4625
+ handle : ORDER_EVENT_HANDLE,
4541
4626
  idempotencyKey : String( orderId ),
4542
4627
  orderDocId,
4543
4628
  orderId : String( orderId ),
@@ -4547,7 +4632,7 @@ var shopify = {
4547
4632
  // here — the event handle plus the order id — and sent as the
4548
4633
  // event's `reference`. queue/usage.js stamps the same id onto
4549
4634
  // the order as billed.transaction.
4550
- transaction : 'drawbridge-orders.' + orderId,
4635
+ transaction : ORDER_EVENT_HANDLE + '.' + orderId,
4551
4636
  value : Math.round( fee * 100 )
4552
4637
  },
4553
4638
  name : 'billing',
@@ -4728,7 +4813,7 @@ var shopify = {
4728
4813
  // access token still works, reconciles the scopes the store granted
4729
4814
  // against the ones the app now needs, and queues a webhook
4730
4815
  // reconciliation.
4731
- health : async ( { connection, workflow }, { adminToken, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {} ) => {
4816
+ health : async ( { connection, workflow }, { adminToken, logger, read, reconcileScopes, resolveSettings, rotateToken, shopify } = {} ) => {
4732
4817
 
4733
4818
  const request = {
4734
4819
  connectionId : workflow.connection,
@@ -4772,7 +4857,102 @@ var shopify = {
4772
4857
  // hook needs its RESULT to compose the message below.
4773
4858
  const scopesMissing = await reconcileScopes({ shop : connection.shop });
4774
4859
 
4860
+ // THE BILLING IDENTITY SELF-HEALS HERE, because nothing else ever
4861
+ // repairs it. `source.id` is written once, at link time, from a
4862
+ // getShop call inside a best-effort catch — a transient miss there
4863
+ // used to leave the connection permanently unbillable while looking
4864
+ // perfectly healthy: the order hook's billing enqueue gates on
4865
+ // `source.id` and skips without it. The daily health check already
4866
+ // holds a live admin token, so a missing source is one lookup away
4867
+ // from fixed.
4868
+ //
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.
4877
+ //
4878
+ // Best-effort by design: the ping above already proved access, so a
4879
+ // blip in either lookup must not error a healthy connection — the
4880
+ // next daily run retries. `currency` is deliberately NOT repaired
4881
+ // here: the connection schema constrains it to the billing-FX enum,
4882
+ // which this package cannot see, so the live value is surfaced in
4883
+ // the response for an operator instead of risking a validation
4884
+ // failure on the write.
4885
+ const writes = [];
4886
+
4887
+ let source = connection.source || null;
4888
+ let sourceRepaired = false;
4889
+ let metered = source?.metered ?? null;
4890
+ let shopCurrency = null;
4891
+ let billingProbeFailed = false;
4892
+
4893
+ try {
4894
+
4895
+ if( ! source?.id ){
4896
+
4897
+ const shopData = await shopify.oauth.getShop({ adminAccessToken, shop : connection.shop });
4898
+
4899
+ source = {
4900
+ domain : shopData.myshopifyDomain,
4901
+ id : String( shopData.id ),
4902
+ label : shopData.name
4903
+ };
4904
+ shopCurrency = String( shopData.currency || '' ).toLowerCase() || null;
4905
+ sourceRepaired = true;
4906
+
4907
+ }
4908
+
4909
+ const { subscriptions } = await shopify.admin.getActiveAppSubscriptions({
4910
+ adminAccessToken,
4911
+ domain : connection.shop
4912
+ });
4913
+
4914
+ // Only an ACTIVE approval says anything about metering — no
4915
+ // subscriptions (mid-install, plan pending) leaves the stamp
4916
+ // untouched rather than asserting false from silence. The
4917
+ // 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.
4921
+ if( subscriptions.length && subscriptions.every( ( subscription ) => typeof subscription.metered === 'boolean' ) ){
4922
+
4923
+ metered = subscriptions.some( ( subscription ) => subscription.metered );
4924
+
4925
+ }
4926
+
4927
+ } catch ( probeError ) {
4928
+
4929
+ billingProbeFailed = true;
4930
+
4931
+ logger?.warn?.( 'shopify.health.billing.probe.failed', {
4932
+ connectionId : connection.id,
4933
+ message : probeError?.message,
4934
+ shop : connection.shop
4935
+ });
4936
+
4937
+ }
4938
+
4939
+ if( sourceRepaired || ( metered !== null && metered !== ( connection.source?.metered ?? null ) ) ){
4940
+
4941
+ writes.push({
4942
+ collection : 'connection',
4943
+ data : {
4944
+ $set : sourceRepaired
4945
+ ? { source : { ...source, ...( metered !== null && { metered }) } }
4946
+ : { 'source.metered' : metered }
4947
+ },
4948
+ operation : 'update',
4949
+ query : { id : connection.id }
4950
+ });
4951
+
4952
+ }
4953
+
4775
4954
  return {
4955
+ writes,
4776
4956
  enqueues : [ {
4777
4957
  data : {
4778
4958
  data : {
@@ -4785,17 +4965,26 @@ var shopify = {
4785
4965
  options : { jobId : 'connection.update.register.' + workflow.connection + '.' + randomUUID() },
4786
4966
  queue : 'connection'
4787
4967
  } ],
4788
- message : scopesMissing?.length
4968
+ message : ( scopesMissing?.length
4789
4969
  ? 'Health check: ping ok, webhooks reconciled — connection errored, granted scopes are missing: ' + scopesMissing.join( ', ' ) + '.'
4790
4970
  : refreshTokenRotated
4791
4971
  ? 'Health check passed — refresh token rotated, ping ok, webhooks reconciled.'
4792
- : 'Health check passed — ping ok, webhooks reconciled.',
4972
+ : 'Health check passed — ping ok, webhooks reconciled.'
4973
+ )
4974
+ + ( 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.' : '' ),
4793
4976
  request,
4794
4977
  response : {
4978
+ billingProbeFailed,
4979
+ metered,
4795
4980
  pingedAt : new Date(),
4796
4981
  refreshTokenExpiresAt : refreshTokenExpiresAt || null,
4797
4982
  refreshTokenRotated,
4798
4983
  scopesMissing,
4984
+ // The live settlement currency, for an operator — never
4985
+ // written, see above.
4986
+ ...( shopCurrency && { shopCurrency }),
4987
+ sourceRepaired,
4799
4988
  webhookReconciliationQueued : true
4800
4989
  }
4801
4990
  };
@@ -5115,15 +5304,38 @@ var shopify = {
5115
5304
  //
5116
5305
  // Scope drift is NOT here: drawbridge-sync writes it onto the connection
5117
5306
  // document, and the document's own warnings render beside these.
5118
- tasks : ( data ) => ( data?.status === 'pending'
5119
- ? [
5120
- {
5121
- message : 'Open the Drawbridge app in your Shopify admin and choose a plan. The connection activates once Shopify confirms it.',
5122
- title : 'Choose a plan in Shopify'
5123
- }
5124
- ]
5125
- : []
5126
- ),
5307
+ // Both cards carry `action : 'billing'`: the dashboard renders it as a real
5308
+ // button that mints the corrective subscription (POST .../billing) and sends
5309
+ // the owner to Shopify's confirmation screen — the fix lives in OUR
5310
+ // dashboard, and the one Shopify-hosted step is the charge consent no app
5311
+ // can perform for a merchant.
5312
+ tasks : ( data ) => [
5313
+ ...( data?.status === 'pending'
5314
+ ? [
5315
+ {
5316
+ 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'
5319
+ }
5320
+ ]
5321
+ : []
5322
+ ),
5323
+ // 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
5329
+ ? [
5330
+ {
5331
+ 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'
5334
+ }
5335
+ ]
5336
+ : []
5337
+ )
5338
+ ],
5127
5339
  title : 'Shopify'
5128
5340
  };
5129
5341
 
@@ -5161,6 +5373,9 @@ var webhook = {
5161
5373
  // Nothing to be exclusive with — there is no second webhook vendor, and a
5162
5374
  // merchant with two endpoints is a step-level choice rather than a second
5163
5375
  // connection.
5376
+ // No vendor-facing event identities — declared empty so the contract stays
5377
+ // explicit: a vendor that gains one adds it here, never as a side channel.
5378
+ events : {},
5164
5379
  exclusive : false,
5165
5380
  feature : 'organization:connection:webhook',
5166
5381
  fields : [