@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.
- package/dist/connections/index.cjs +175 -29
- package/dist/connections/index.d.cts +230 -15
- package/dist/connections/index.d.ts +230 -15
- package/dist/connections/index.js +175 -29
- package/dist/providers.cjs +181 -35
- package/dist/providers.d.cts +2 -0
- package/dist/providers.d.ts +2 -0
- package/dist/providers.js +181 -35
- package/package.json +1 -1
|
@@ -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
|
-
'
|
|
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
|
|
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 : '
|
|
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
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
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 : [
|