@openpartner/sdk 0.1.0 → 0.3.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,79 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ Closes the places where a partner click could go missing in the browser, and
6
+ reports clicks that arrive so Admin → Tracking health can show the ones that
7
+ don't. No breaking API changes; the one new default behaviour is the landing
8
+ report (see Compatibility).
9
+
10
+ ### Added
11
+
12
+ - **Consent mode.** `consent: 'granted' | 'pending' | 'denied'` in
13
+ `OpenPartner.init()` (script tag: `data-consent="pending"`) and
14
+ `setConsent('granted' | 'denied')`.
15
+ - `'pending'` holds the click from the URL in memory only. Nothing is read
16
+ from or written to storage or cookies, no Stripe link is tagged and no
17
+ request is sent until `setConsent('granted')`, which then works even
18
+ after a single-page app has moved off the landing URL.
19
+ - `'denied'`, at load or later, deletes what an earlier visit stored and
20
+ takes the `client_reference_id` the SDK added back off Stripe Payment
21
+ Links and embeds. A reference you set yourself stays. Safe to call more
22
+ than once.
23
+ - The default is `'granted'`: integrations that don't set it behave as
24
+ before.
25
+ - **Landing report.** When a click first arrives from the URL, the SDK posts
26
+ its id once to `POST /attribution/landing` (the page's hostname comes from
27
+ the browser's `Origin` header; nothing else about the visitor is sent).
28
+ Admin → Tracking health compares landings with clicks to show the clicks
29
+ lost between the partner link and your page. Turn it off with
30
+ `reportLanding: false` (script tag: `data-landing="off"`).
31
+ - **Server-side referral cookie** in `@openpartner/sdk/server`, for Safari,
32
+ which deletes storage a page's scripts wrote after 7 days without a visit.
33
+ A cookie your own server sets on the landing response isn't capped that way,
34
+ and the browser SDK reads it back.
35
+ - `referralCookieHeader(url, options?)`: the `Set-Cookie` value for a
36
+ request whose URL carries `?cref=`, or `null`.
37
+ - `referralCookieMiddleware(options?)`: Express / Connect middleware that
38
+ sets it, adding to any `Set-Cookie` already queued.
39
+ - `readReferralCookie(cookieHeader, name?)`: the referral off a `Cookie`
40
+ header, for `identify()` from your backend or `client_reference_id` on a
41
+ Checkout Session.
42
+
43
+ ### Changed
44
+
45
+ - **The referral is read back from the `op_ref` cookie** (or
46
+ `referralCookie.name`) when localStorage is empty: on a second subdomain
47
+ (land on `www.`, sign up on `app.` with `referralCookie.domain` /
48
+ `data-cookie-domain` set), after Safari clears localStorage, or where
49
+ localStorage is blocked. Previously only localStorage and the router's
50
+ `_cref` cookie were read.
51
+ - **An `op_ref` cookie that already holds the referral isn't rewritten** from
52
+ script, so one your server set keeps its lifetime in Safari. A new click
53
+ still replaces it.
54
+ - **A click from the URL is kept in memory as well,** so the referral cookie,
55
+ Stripe tagging and `identify()` still get it where localStorage throws.
56
+ - **`clear()` also expires the referral cookie** and removes the Stripe
57
+ references the SDK added.
58
+ - **`onError`'s default log line** is now `[openpartner] request failed` (was
59
+ `identify failed`): it also covers the landing report.
60
+
61
+ ### Compatibility
62
+
63
+ - The landing report is on by default and needs an OpenPartner API that has
64
+ `POST /attribution/landing` (OpenPartner Cloud, or a self-hosted instance
65
+ from October 24 2026 on). An older API answers 404, and the SDK ignores it:
66
+ only a `click_not_found` from a current API reaches `onError`, because that
67
+ usually means `apiUrl` names another program.
68
+ - The script tag an OpenPartner portal serves at `/op.js` is built from this
69
+ source and already behaves this way.
70
+
71
+ ## 0.2.0
72
+
73
+ Published to npm on October 20 2026: Stripe Payment Link and Pricing Table /
74
+ Buy Button linking, the script-tag build, the `op_ref` referral cookie, and the
75
+ server SDK (`trackEvent`, `trackConversion`, `identify`).
76
+
77
+ ## 0.1.0
78
+
79
+ The first published version.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Client + server SDK for [OpenPartner](https://github.com/getcoherence/openpartner).
4
4
 
5
- - **Browser** — captures the `cref` attribution token from landing URLs and the first-party cookie, survives Safari / ITP cookie expiry via localStorage, and stitches to a logged-in user on auth.
5
+ - **Browser** — captures the `cref` attribution token from landing URLs and first-party cookies, keeps it across pages and subdomains, waits for consent when you ask it to, and stitches to a logged-in user on auth.
6
6
  - **Server** — posts conversion events, stitches server-side where needed, with typed errors.
7
7
 
8
8
  Zero runtime dependencies. ~4 KB gzipped browser bundle.
@@ -13,7 +13,7 @@ npm install @openpartner/sdk
13
13
 
14
14
  ## Browser
15
15
 
16
- Embed once on every page of your marketing site and product. The SDK runs on init, pulling the cref out of `?cref=…` (first visit from a partner link) or the `_cref` cookie (subsequent pages) and stashing it in localStorage.
16
+ Embed once on every page of your marketing site and product. The SDK runs on init, pulling the cref out of `?cref=…` (first visit from a partner link), or on later pages out of localStorage or a first-party cookie (`_cref` from the click router, `op_ref` from a sibling subdomain or your server), and stashing it in localStorage.
17
17
 
18
18
  ```ts
19
19
  import { OpenPartner } from '@openpartner/sdk';
@@ -38,9 +38,84 @@ OpenPartner.init({
38
38
  queryParam: 'cref', // query param on landing URLs
39
39
  storageKey: 'openpartner:cref', // localStorage key
40
40
  onError: (err) => Sentry.captureException(err),
41
+ stripe: { linkHosts: ['pay.yourbrand.com'] }, // or false — see below
42
+ referralCookie: { name: 'op_ref', domain: '.yourbrand.com' }, // off by default
43
+ consent: 'pending', // default 'granted' — see Consent below
44
+ reportLanding: true, // default on — see Landing report below
41
45
  });
42
46
  ```
43
47
 
48
+ ### Subdomains
49
+
50
+ A visitor who lands on `www.yourbrand.com` and signs up on `app.yourbrand.com` has two localStorages, one per subdomain. Share the referral through a cookie on the parent domain: `referralCookie: { domain: '.yourbrand.com' }` (script tag: `data-cookie-domain=".yourbrand.com"`) on every subdomain. The SDK on `app.` reads `op_ref` back when its own storage is empty, so `identify()` and Stripe links work there too. A journey across two different domains (`yourbrand.com` → `yourbrand-shop.com`) has no shared cookie: carry `?cref=` on the link between them.
51
+
52
+ ### Safari and long gaps
53
+
54
+ Safari deletes storage a page's scripts wrote (localStorage, and cookies set through `document.cookie`) after 7 days without a visit. A visitor who clicks, comes back after a fortnight and buys without signing up first would arrive with nothing. Two things close that gap:
55
+
56
+ - **Call `identify()` as early as you can.** Once a click is stitched to a user, the link lives on OpenPartner's side and no browser storage matters. An email capture or account creation before checkout is enough.
57
+ - **Set the referral cookie from your own server**, on the landing page's response. A cookie your server sets keeps its full lifetime in Safari, and the browser SDK reads it back:
58
+
59
+ ```ts
60
+ import { referralCookieHeader, referralCookieMiddleware } from '@openpartner/sdk/server';
61
+
62
+ // Express / Connect:
63
+ app.use(referralCookieMiddleware({ domain: '.yourbrand.com' }));
64
+
65
+ // Anything else (Next.js middleware, a Worker): set the header yourself.
66
+ const header = referralCookieHeader(request.url, { domain: '.yourbrand.com' });
67
+ if (header) response.headers.append('Set-Cookie', header);
68
+ ```
69
+
70
+ Set it from the server that serves the page. Safari caps a cookie at 7 days when it comes from a server on a different IP address than the site, such as an API hosted on another provider. The SDK never rewrites an `op_ref` cookie that already holds the referral, because rewriting it from script would bring the 7-day cap back.
71
+
72
+ ### Consent
73
+
74
+ Behind a consent banner, start the SDK with `consent: 'pending'` and tell it the answer:
75
+
76
+ ```ts
77
+ const op = OpenPartner.init({ apiUrl, consent: 'pending' });
78
+
79
+ yourConsentBanner.onChange((choices) => {
80
+ op.setConsent(choices.marketing ? 'granted' : 'denied');
81
+ });
82
+ ```
83
+
84
+ While consent is pending, the SDK keeps the click from the URL in memory and does nothing else: it doesn't read or write storage or cookies, tag Stripe links, or call OpenPartner. `setConsent('granted')` then works as if the page had just loaded, even after the visitor has navigated within a single-page app. `setConsent('denied')` stops everything, takes the references the SDK added back off Stripe links and embeds (one you set yourself stays), and deletes what was stored. Starting with `consent: 'denied'` deletes what an earlier visit stored. `getReferral()` and `identify()` return `null` until consent is granted. Script tag: `data-consent="pending"`, then `openpartner.setConsent('granted')`.
85
+
86
+ Clicks from visitors who never consent can't be credited, and Admin → Tracking health counts them among the clicks that never landed.
87
+
88
+ ### Landing report
89
+
90
+ When a partner click first arrives from the URL, the SDK tells OpenPartner (`POST /attribution/landing`, the click id and nothing else; the page's hostname comes from the browser's `Origin` header). **Admin → Tracking health** compares landings with clicks to show the ones lost between the partner link and your page: a redirect that dropped `?cref=`, a page without the script, a consent banner. A `click_not_found` from it reaches `onError` and usually means `apiUrl` names another program; any other failure is dropped, because the report is a diagnostic and never part of attribution. Turn it off with `reportLanding: false` (script tag: `data-landing="off"`).
91
+
92
+ ### Stripe links and embeds
93
+
94
+ When the visitor came from a partner link, the SDK hands that click to Stripe for you:
95
+
96
+ - Every Payment Link on the page (`buy.stripe.com`, plus any `linkHosts` you list for a custom Payment Link domain) gets `client_reference_id`.
97
+ - Every `<stripe-pricing-table>` and `<stripe-buy-button>` embed gets `client-reference-id`.
98
+
99
+ That includes elements added after the page loads. A link or embed that already carries a reference keeps it. With your Stripe account connected to OpenPartner (or your self-host's webhook pointed at it), those checkouts then credit the partner with no code at all. `stripe: false` turns it off. For an app-created Checkout Session, pass `op.getReferral()`, or the `op_ref` cookie with `referralCookie` set, as `client_reference_id`.
100
+
101
+ Pricing Table and Buy Button embeds read `client-reference-id` when Stripe renders them, so load the SDK before Stripe's embed script.
102
+
103
+ ### Script tag (no bundler)
104
+
105
+ An OpenPartner portal serves a self-starting build of this SDK at `/op.js`. Paste it into your site's `<head>`:
106
+
107
+ ```html
108
+ <script src="https://app.openpartner.dev/op.js" data-api-url="https://app.openpartner.dev/api/t/your-slug"></script>
109
+ ```
110
+
111
+ **Admin → Integrations** shows the exact tag for your program. It does everything above, keeps the referral in an `op_ref` cookie on your domain, and exposes `window.openpartner` (`openpartner.identify(userId)`, `openpartner.getReferral()`, `openpartner.setConsent('granted')`). Optional attributes:
112
+ - `data-stripe-hosts="pay.yourbrand.com"`: a custom Payment Link domain.
113
+ - `data-cookie-domain=".yourbrand.com"`: share `op_ref` across subdomains.
114
+ - `data-consent="pending"`: wait for `openpartner.setConsent()` (see [Consent](#consent)).
115
+ - `data-stripe="off"`, `data-cookie="off"`, `data-landing="off"`: turn any of them off.
116
+
117
+ The npm package carries the same file at `dist-global/openpartner.js`.
118
+
44
119
  ### Framework notes
45
120
 
46
121
  - **React / Vue / SolidJS**: call `OpenPartner.init()` once at the top of your app. Call `op.identify()` from your auth callback.
@@ -68,10 +143,13 @@ await op.trackEvent({
68
143
  type: 'invoice_paid',
69
144
  value: 249,
70
145
  currency: 'USD',
146
+ externalEventId: invoice.id, // makes retries safe; see below
71
147
  metadata: { stripeInvoiceId: invoice.id },
72
148
  });
73
149
  ```
74
150
 
151
+ Pass `externalEventId` on every call. Use your system's stable id for that one event, such as the invoice id or order id. Your job queue or HTTP client will retry when a request times out. Without a key, each retry records a second event and pays a second commission. With a key, a retry resolves with the original `eventId` and attribution plus `replayed: true`, and nothing new is written. That includes duplicates sent at the same moment. Use one key per event, not per order: an order's `subscription_created` and its `invoice_paid` need different keys. Reusing a key for a different `userId`, `type`, `value` or `currency` throws an `OpenPartnerError` with code `external_event_id_conflict` (409).
152
+
75
153
  The response includes the attribution result:
76
154
 
77
155
  ```ts
@@ -88,15 +166,71 @@ The response includes the attribution result:
88
166
  }
89
167
  ```
90
168
 
169
+ A replay adds `replayed: true`. Its `attribution` is rebuilt from the rows the first call wrote. If that call didn't attribute, the status is `'not_attributed'`, because the specific reason isn't stored.
170
+
91
171
  For multi-touch campaigns (`linear`, `position`, etc.) `touches` will contain one entry per attributed click with a fractional `weight`.
92
172
 
93
- ### Stripe webhooks
173
+ ### Conversions by click id
174
+
175
+ Sometimes the system that learns about the conversion only has the click id, not a user you `identify()`'d. A lender funds a loan, or a CRM closes a deal from a lead form that captured the click id. The click id reaches it from the partner link: as `?cref=…` on the destination URL, or in any parameter you choose, by putting `{click_id}` in the program's destination URL (`https://apply.example.com/?ref={click_id}`). Report the conversion in one call:
176
+
177
+ ```ts
178
+ await op.trackConversion({
179
+ clickId, // from ?cref= or your {click_id} parameter
180
+ type: 'loan_funded',
181
+ value: 5000,
182
+ currency: 'USD',
183
+ externalEventId: `${loan.id}:funded`, // required
184
+ });
185
+ ```
186
+
187
+ OpenPartner stitches the click and records the event together. Without a `userId` the conversion goes to the click's own user (`click:<clickId>`), so every conversion you report for that click is credited to it, and a "first" commission rule fires once. Pass `userId` when you also identify that user elsewhere. Attribution then follows the program's model across all of that user's clicks. `externalEventId` is required and behaves as on `trackEvent`. An unknown click throws `click_not_found` (404). A click flagged as fraud throws `click_flagged` (422). The response adds `userId` to `trackEvent`'s shape.
188
+
189
+ Senders that can only fire a URL (affiliate networks, many lenders) use the postback URL from the admin Integrations page instead: `GET /postback?click_id=…&type=…&amount=…&currency=…&txn_id=…&token=…`. Its token records conversions and nothing else.
190
+
191
+ ### Stripe
192
+
193
+ How Stripe revenue reaches OpenPartner depends on where OpenPartner runs.
194
+
195
+ **Hosted OpenPartner.** Connect your Stripe account under **Admin → Integrations → Connect Stripe**. That installs the OpenPartner Stripe App on it (beta; read-only except coupons and promotion codes), and your Checkout and Payment Link purchases, invoices, renewals, refunds and disputes are then recorded from Stripe, with no `trackEvent` for them. Customers are linked to the partner click the same way as on self-host, below: the [script tag](#script-tag-no-bundler) does it for Payment Links and embeds on your site, and a Checkout Session your backend creates passes `client_reference_id`. With Stripe connected, a `trackEvent` keyed on a Stripe invoice or Checkout Session id (`in_…`, `cs_…`) throws `recorded_from_stripe` (409): Stripe's record of the payment wins. See [openpartner.dev/docs/stripe-app](https://openpartner.dev/docs/stripe-app). Don't point a webhook from your Stripe account at OpenPartner's `/webhooks/stripe`: on hosted it accepts only events signed with OpenPartner's own Stripe signing secrets.
196
+
197
+ **Self-hosted, single tenant.** If the instance's `STRIPE_SECRET_KEY` and webhook signing secret (`STRIPE_WEBHOOK_SECRET_PLATFORM`, or the legacy `STRIPE_WEBHOOK_SECRET`) belong to *your* Stripe account, you can skip `trackEvent` for Stripe revenue. OpenPartner takes revenue from `invoice.paid` (subscriptions and other invoices) and from paid one-time Checkout Sessions (`mode: 'payment'`, including Payment Links). Point a webhook in your Stripe account at `https://<your-openpartner-api>/webhooks/stripe`. Subscribe it to `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `customer.created`, `customer.subscription.created`, `invoice.paid`, `charge.refunded` and `charge.dispute.created`.
198
+
199
+ The endpoint can use any Stripe API version, including the one Stripe preselects when you create it. Version `2025-03-31.basil` removed the invoice ↔ payment pointers from webhook payloads (`Invoice.charge`, `Invoice.payment_intent`, `Charge.invoice`). On basil and later, OpenPartner reads a refunded charge's invoice back from Stripe's InvoicePayments: one Stripe API call per refund of an invoice's charge.
200
+
201
+ OpenPartner has to know which Stripe customer is which referred user. Link them in one of two ways:
202
+
203
+ - **Checkout `client_reference_id`.** Pass the captured referral through when you create the Checkout Session:
204
+
205
+ ```ts
206
+ // Browser: the captured referral, or null if the visitor didn't come from a partner link.
207
+ const ref = op.getReferral();
208
+
209
+ // Your backend, with `ref` sent up from the browser:
210
+ const session = await stripe.checkout.sessions.create({
211
+ client_reference_id: ref ?? undefined,
212
+ // ...line_items, mode, success_url, etc.
213
+ });
214
+ ```
215
+
216
+ With the [script tag](#script-tag-no-bundler) (or `referralCookie` set), your backend can read the referral straight off the request instead: `client_reference_id: req.cookies.op_ref`, or `readReferralCookie(req.headers.cookie)` from `@openpartner/sdk/server` without a cookie parser. The same call lets your backend stitch at signup, which works even where browser scripts are blocked: `await op.identify({ cref, userId: user.id })`.
217
+
218
+ A Stripe Payment Link can't run your code, so let the partner link fill it in: set the program's destination URL to the Payment Link with `?client_reference_id={click_id}`, and the click router puts the click id there.
219
+
220
+ On `checkout.session.completed`, OpenPartner stitches the session's customer to that click and records a `signup` event. A paid one-time session also records its `amount_total` as an `invoice_paid` event, keyed on the session id. A guest checkout with no Stripe Customer, the Payment Link default, is keyed to the click's own user (`click:<clickId>`). A delayed payment method (SEPA, ACH, Bacs) records the revenue at `checkout.session.async_payment_succeeded`, when the money actually clears. Subscription sessions record no revenue at checkout, because their `invoice.paid` carries it. Neither does a session with `invoice_creation` enabled, for the same reason. The customer's events are keyed to the Stripe customer id (`cus_…`). Stripe doesn't deliver events in order. If the first `invoice.paid` or `customer.subscription.created` arrives before `checkout.session.completed`, OpenPartner looks up the customer's completed Checkout Sessions in Stripe and stitches then.
221
+ - **Customer metadata `openpartner_user_id`.** Set it on the Stripe Customer to the user id you pass to `identify()`. Set it before the customer pays, for example by creating the Customer with the metadata and passing `customer` when you create the Checkout Session. An invoice that arrives before the metadata is set is skipped, and Stripe won't redeliver it.
222
+
223
+ You can use both. If the Customer already carries your `openpartner_user_id` when the checkout completes, OpenPartner stitches the click to that id instead of the `cus_…` id and leaves your metadata as you set it. The user gets one signup, although `customer.created` and `checkout.session.completed` both report it.
224
+
225
+ Once a customer is linked, OpenPartner ingests and attributes their `customer.subscription.created` and `invoice.paid` automatically, and their later one-time Checkout purchases even without a `client_reference_id`. A refund of an invoice's or one-time purchase's charge (`charge.refunded`) leaves each of that payment's commissions worth its original amount times the share of the collected amount the customer kept, so a full refund keeps nothing. Partial refunds add up. Accrued and approved commissions are reversed, and on a partial refund a replacement for the kept share is accrued with the same status and accrual date. Paid commissions stay paid: a `refund_clawback` adjustment records what the partner holds, and OpenPartner takes it off that partner's next manual or self-hosted Stripe Connect payout. Stripe Connect payouts on hosted OpenPartner don't deduct it, so recover those by hand. A commission in a payout that's in progress is held until the next refund of that charge. Since `2025-03-31.basil`, an invoice can be paid in several payments, and one payment can pay several invoices. A refund of such a payment is recorded but reverses nothing, because the charge doesn't say how much of which invoice it gave back. Report it with `POST /attribution/refunds` and the amount. A dispute is recorded and reverses nothing. If you lose it, report the chargeback with `POST /attribution/refunds` (`"kind": "chargeback"`). Don't also `trackEvent` the same invoices from your backend, or the revenue is counted twice.
94
226
 
95
- OpenPartner's API has a built-in `/webhooks/stripe` handler that ingests `customer.created`, `customer.subscription.created`, and `invoice.paid` automatically. If you're already using Stripe with OpenPartner, point your Stripe webhook at OpenPartner's API directly — you don't need to call `trackEvent` from your own code.
227
+ If `STRIPE_SECRET_KEY` is a restricted key, give it read access to Customers, Checkout Sessions and Invoices. Invoices covers a promo code applied to an invoice, and on API versions from `2025-03-31.basil` the InvoicePayment that names a refunded charge's invoice. When the key isn't allowed to read one of them, OpenPartner skips that lookup instead of failing the webhook, and the event can go unattributed.
96
228
 
97
229
  Call `trackEvent` from your backend when:
98
230
 
99
- - You don't use Stripe (or Stripe isn't the source of the conversion).
231
+ - You're on hosted OpenPartner and haven't connected Stripe.
232
+ - You don't use Stripe, or the customer is linked neither by a Checkout `client_reference_id` nor by `openpartner_user_id` metadata.
233
+ - The revenue doesn't come through Stripe Checkout or an invoice, such as a PaymentIntent your backend charges directly. `payment_intent.*` and `charge.succeeded` aren't ingested as revenue.
100
234
  - You want to track custom event types (`trial_started`, `feature_activated`, etc.).
101
235
  - You want to override the event timestamp (`ts: '2026-04-20T12:00:00Z'`).
102
236
 
@@ -144,7 +278,7 @@ attribution engine ← Applies campaign model, emits Attribution + C
144
278
 
145
279
  - **click** — visitor lands on `https://vendor.example.com/r/<slug>`. The OpenPartner router sets `_cref` cookie and writes a `Click` row.
146
280
  - **identify** — once the visitor authenticates on the vendor's site, the browser SDK (or server SDK) calls `/attribution/identify` which writes an `Identity` row stitching `cref → userId`.
147
- - **trackEvent** — any subsequent conversion event is written as an `Event` row. The attribution engine walks `Event.userId → Identity → Click → Campaign`, checks the attribution window, applies the configured model (`last_click` / `first_click` / `linear` / `position`), and writes `Attribution` + `Commission` rows.
281
+ - **trackEvent** — any subsequent conversion event is written as an `Event` row. The attribution engine walks `Event.userId → Identity → Click → Campaign`, checks the attribution window (a repeat of a conversion the user already made, such as a renewal, keeps that conversion's partner past it), applies the configured model (`last_click` / `first_click` / `linear` / `position`), and writes `Attribution` + `Commission` rows.
148
282
 
149
283
  ## License
150
284
 
package/dist/browser.cjs CHANGED
@@ -73,23 +73,44 @@ var DEFAULTS = {
73
73
  cookieName: "_cref",
74
74
  queryParam: "cref",
75
75
  storageKey: "openpartner:cref",
76
+ stripe: true,
77
+ referralCookie: false,
78
+ consent: "granted",
79
+ reportLanding: true,
76
80
  onError: (err) => {
77
- console.warn("[openpartner] identify failed", err);
81
+ console.warn("[openpartner] request failed", err);
78
82
  }
79
83
  };
84
+ var STRIPE_REFERENCE_RE = /^[A-Za-z0-9_-]{1,200}$/;
85
+ var STRIPE_EMBED_SELECTOR = "stripe-pricing-table, stripe-buy-button";
86
+ var REFERRAL_COOKIE_DEFAULT = "op_ref";
80
87
  var OpenPartner = class _OpenPartner {
81
88
  constructor(config) {
82
89
  __publicField(this, "config");
90
+ __publicField(this, "observer", null);
91
+ __publicField(this, "consent");
92
+ /** The URL's referral while consent is pending, held in memory only. */
93
+ __publicField(this, "held", null);
94
+ /** This page's referral, kept here too: where localStorage throws it is
95
+ * the only copy until the cookie is written. */
96
+ __publicField(this, "current", null);
97
+ /** The Stripe links and embeds this SDK tagged, and with what, so a
98
+ * withdrawn consent can take the tags back off. */
99
+ __publicField(this, "tagged", []);
83
100
  this.config = { ...DEFAULTS, ...config };
84
- this.captureCref();
101
+ this.consent = this.config.consent;
102
+ if (this.consent === "granted") this.start();
103
+ else if (this.consent === "pending") this.held = this.queryCref();
104
+ else this.clear();
85
105
  }
86
106
  static init(config) {
87
107
  return new _OpenPartner(config);
88
108
  }
89
- /** The cref we're holding onto, if any. Useful for debugging. */
109
+ /** The cref we're holding onto, if any. Useful for debugging. Null until
110
+ * consent is granted. */
90
111
  getCref() {
91
- if (typeof localStorage === "undefined") return null;
92
- return localStorage.getItem(this.config.storageKey);
112
+ if (this.consent !== "granted") return null;
113
+ return this.storageGet() ?? this.current ?? this.readCookie(this.referralCookieName());
93
114
  }
94
115
  /**
95
116
  * Captured referral token for this visitor. Pass to Stripe Checkout
@@ -101,47 +122,260 @@ var OpenPartner = class _OpenPartner {
101
122
  * // ...
102
123
  * });
103
124
  *
104
- * Returns null if the visitor didn't arrive via a partner link.
125
+ * Returns null if the visitor didn't arrive via a partner link, or
126
+ * hasn't agreed to tracking yet.
105
127
  */
106
128
  getReferral() {
107
129
  return this.getCref();
108
130
  }
109
- /** Explicitly discard the captured cref. */
131
+ /** Explicitly discard the captured cref, the referral cookie, and the
132
+ * references this SDK added to Stripe links and embeds. */
110
133
  clear() {
111
- if (typeof localStorage === "undefined") return;
112
- localStorage.removeItem(this.config.storageKey);
134
+ this.held = null;
135
+ this.current = null;
136
+ this.untagStripe();
137
+ this.storageRemove();
138
+ if (typeof document === "undefined") return;
139
+ const name = this.referralCookieName();
140
+ if (!this.readCookie(name)) return;
141
+ const cfg = this.config.referralCookie;
142
+ document.cookie = `${name}=; Max-Age=0; Path=/`;
143
+ if (cfg && cfg.domain) document.cookie = `${name}=; Max-Age=0; Path=/; Domain=${cfg.domain}`;
113
144
  }
114
145
  /**
115
- * Capture the cref from query string or cookie and stash in
116
- * localStorage. Runs on init so the value survives SPA navigations,
117
- * ITP cookie expiry, and subsequent page loads.
146
+ * Record the visitor's answer to your consent banner. `'granted'` stores
147
+ * the referral (the one held since the page loaded, or one already
148
+ * stored), starts tagging Stripe links and sends the landing report.
149
+ * `'denied'` stops all of it, takes the references it added off Stripe
150
+ * links and embeds, and deletes what was stored. Calling it again is
151
+ * safe.
118
152
  */
119
- captureCref() {
120
- if (typeof window === "undefined" || typeof localStorage === "undefined") return;
121
- const url = new URL(window.location.href);
122
- const queryCref = url.searchParams.get(this.config.queryParam);
123
- if (queryCref) {
124
- localStorage.setItem(this.config.storageKey, queryCref);
153
+ setConsent(state) {
154
+ if (state === "granted") {
155
+ if (this.consent === "granted") return;
156
+ this.consent = state;
157
+ this.start();
125
158
  return;
126
159
  }
127
- const cookieCref = this.readCookie(this.config.cookieName);
128
- if (cookieCref && !localStorage.getItem(this.config.storageKey)) {
129
- localStorage.setItem(this.config.storageKey, cookieCref);
160
+ this.consent = state;
161
+ this.stopLinkingStripe();
162
+ this.clear();
163
+ }
164
+ /**
165
+ * Add the referral to every Stripe Payment Link and Pricing Table / Buy
166
+ * Button embed under `root` that doesn't carry a reference already.
167
+ * Runs on its own at init and for anything added later; call it yourself
168
+ * only if you build Stripe elements inside a shadow root. Returns how
169
+ * many it changed.
170
+ */
171
+ linkStripe(root = document) {
172
+ const referral = this.getReferral();
173
+ if (!referral || !STRIPE_REFERENCE_RE.test(referral) || typeof document === "undefined") return 0;
174
+ const hosts = /* @__PURE__ */ new Set(["buy.stripe.com", ...this.stripeLinkHosts()]);
175
+ let changed = 0;
176
+ const anchors = [];
177
+ if (root instanceof HTMLAnchorElement) anchors.push(root);
178
+ anchors.push(...Array.from(root.querySelectorAll("a[href]")));
179
+ for (const a of anchors) {
180
+ let url;
181
+ try {
182
+ url = new URL(a.href);
183
+ } catch {
184
+ continue;
185
+ }
186
+ if (!hosts.has(url.hostname.toLowerCase()) || url.searchParams.has("client_reference_id")) continue;
187
+ url.searchParams.set("client_reference_id", referral);
188
+ a.href = url.toString();
189
+ this.remember(a, referral);
190
+ changed += 1;
191
+ }
192
+ const embeds = [];
193
+ if (root instanceof Element && root.matches(STRIPE_EMBED_SELECTOR)) embeds.push(root);
194
+ embeds.push(...Array.from(root.querySelectorAll(STRIPE_EMBED_SELECTOR)));
195
+ for (const el of embeds) {
196
+ if (el.hasAttribute("client-reference-id")) continue;
197
+ el.setAttribute("client-reference-id", referral);
198
+ this.remember(el, referral);
199
+ changed += 1;
200
+ }
201
+ return changed;
202
+ }
203
+ remember(el, value) {
204
+ this.tagged = this.tagged.filter((t) => t.el.isConnected);
205
+ this.tagged.push({ el, value });
206
+ }
207
+ /** Take back the references this SDK added. One the brand set, or one
208
+ * changed since, stays. */
209
+ untagStripe() {
210
+ for (const { el, value } of this.tagged) {
211
+ if (el instanceof HTMLAnchorElement) {
212
+ let url;
213
+ try {
214
+ url = new URL(el.href);
215
+ } catch {
216
+ continue;
217
+ }
218
+ if (url.searchParams.get("client_reference_id") !== value) continue;
219
+ url.searchParams.delete("client_reference_id");
220
+ el.href = url.toString();
221
+ } else if (el.getAttribute("client-reference-id") === value) {
222
+ el.removeAttribute("client-reference-id");
223
+ }
224
+ }
225
+ this.tagged = [];
226
+ }
227
+ /** Stop watching the page for new Stripe links and embeds. */
228
+ stopLinkingStripe() {
229
+ this.observer?.disconnect();
230
+ this.observer = null;
231
+ }
232
+ stripeLinkHosts() {
233
+ const cfg = this.config.stripe;
234
+ return typeof cfg === "object" && cfg.linkHosts ? cfg.linkHosts.map((h) => h.trim().toLowerCase()).filter(Boolean) : [];
235
+ }
236
+ /** Everything the SDK does once it may: store, mirror, link, report. */
237
+ start() {
238
+ const landed = this.captureCref(this.held);
239
+ this.held = null;
240
+ this.writeReferralCookie();
241
+ if (this.config.stripe !== false) this.watchStripe();
242
+ if (landed && this.config.reportLanding !== false) void this.reportLanding(landed);
243
+ }
244
+ /**
245
+ * Link what's on the page now, then anything added or re-pointed later
246
+ * (SPAs, Stripe embeds mounted on demand). Only with a referral: a visitor
247
+ * who didn't come from a partner link costs nothing.
248
+ *
249
+ * Pricing Table and Buy Button embeds read client-reference-id when
250
+ * Stripe's script renders them, so the script tag belongs in <head>,
251
+ * ahead of the embed: the observer then sets the attribute as the
252
+ * element is parsed, before Stripe upgrades it.
253
+ */
254
+ watchStripe() {
255
+ if (typeof document === "undefined" || this.observer || !this.getReferral()) return;
256
+ this.linkStripe(document);
257
+ if (typeof MutationObserver === "undefined") return;
258
+ this.observer = new MutationObserver((records) => {
259
+ for (const record of records) {
260
+ if (record.type === "attributes") {
261
+ if (record.target instanceof HTMLAnchorElement) this.linkStripe(record.target);
262
+ continue;
263
+ }
264
+ for (const node of Array.from(record.addedNodes)) {
265
+ if (node instanceof Element) this.linkStripe(node);
266
+ }
267
+ }
268
+ });
269
+ this.observer.observe(document.documentElement, {
270
+ childList: true,
271
+ subtree: true,
272
+ attributes: true,
273
+ attributeFilter: ["href"]
274
+ });
275
+ }
276
+ /** The cref on the landing URL, if any. */
277
+ queryCref() {
278
+ if (typeof window === "undefined") return null;
279
+ return new URL(window.location.href).searchParams.get(this.config.queryParam);
280
+ }
281
+ /**
282
+ * Capture the cref from the URL (or the one held while consent was
283
+ * pending), else the router's cookie, else the referral cookie, and
284
+ * stash it in localStorage so it survives SPA navigations and later
285
+ * page loads. Returns the cref when it's a click arriving now, one this
286
+ * browser didn't have yet, for the landing report.
287
+ */
288
+ captureCref(held) {
289
+ if (typeof window === "undefined") return null;
290
+ const incoming = held ?? this.queryCref();
291
+ if (incoming) {
292
+ const isNew = this.storageGet() !== incoming;
293
+ this.current = incoming;
294
+ this.storageSet(incoming);
295
+ return isNew ? incoming : null;
296
+ }
297
+ if (this.storageGet()) return null;
298
+ const restored = this.readCookie(this.config.cookieName) ?? this.readCookie(this.referralCookieName());
299
+ if (restored) {
300
+ this.current = restored;
301
+ this.storageSet(restored);
130
302
  }
303
+ return null;
304
+ }
305
+ referralCookieName() {
306
+ const cfg = this.config.referralCookie;
307
+ return cfg && cfg.name || REFERRAL_COOKIE_DEFAULT;
308
+ }
309
+ /**
310
+ * Mirror the referral into the brand's own first-party cookie (when
311
+ * configured). Left alone when it already holds this referral: in Safari
312
+ * rewriting it from script would trade a server-set cookie's full
313
+ * lifetime for the 7-day cap. A new click overwrites it.
314
+ */
315
+ writeReferralCookie() {
316
+ const cfg = this.config.referralCookie;
317
+ if (!cfg || typeof document === "undefined") return;
318
+ const referral = this.getReferral();
319
+ if (!referral || !STRIPE_REFERENCE_RE.test(referral)) return;
320
+ const name = this.referralCookieName();
321
+ if (this.readCookie(name) === referral) return;
322
+ const maxAge = Math.round((cfg.days ?? 90) * 86400);
323
+ const secure = typeof location !== "undefined" && location.protocol === "https:" ? "; Secure" : "";
324
+ const domain = cfg.domain ? `; Domain=${cfg.domain}` : "";
325
+ document.cookie = `${name}=${encodeURIComponent(referral)}; Max-Age=${maxAge}; Path=/; SameSite=Lax${domain}${secure}`;
131
326
  }
132
327
  readCookie(name) {
133
328
  if (typeof document === "undefined") return null;
134
329
  const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));
135
- return match ? decodeURIComponent(match[2]) : null;
330
+ if (!match) return null;
331
+ try {
332
+ return decodeURIComponent(match[2]);
333
+ } catch {
334
+ return null;
335
+ }
336
+ }
337
+ // localStorage throws in some sandboxed iframes and with site data
338
+ // blocked; the referral then lives in the cookie alone.
339
+ storageGet() {
340
+ try {
341
+ return typeof localStorage === "undefined" ? null : localStorage.getItem(this.config.storageKey);
342
+ } catch {
343
+ return null;
344
+ }
345
+ }
346
+ storageSet(value) {
347
+ try {
348
+ if (typeof localStorage !== "undefined") localStorage.setItem(this.config.storageKey, value);
349
+ } catch {
350
+ }
351
+ }
352
+ storageRemove() {
353
+ try {
354
+ if (typeof localStorage !== "undefined") localStorage.removeItem(this.config.storageKey);
355
+ } catch {
356
+ }
357
+ }
358
+ /** Tell OpenPartner the click arrived. Best-effort, once per click. */
359
+ async reportLanding(cref) {
360
+ if (!STRIPE_REFERENCE_RE.test(cref) || typeof fetch === "undefined") return;
361
+ try {
362
+ await requestJson(`${this.config.apiUrl}/attribution/landing`, {
363
+ method: "POST",
364
+ body: { cref },
365
+ keepalive: true
366
+ });
367
+ } catch (err) {
368
+ if (err instanceof OpenPartnerError && err.code === "click_not_found") this.config.onError(err);
369
+ }
136
370
  }
137
371
  /**
138
372
  * Stitch the current captured click to an authenticated user. Call
139
373
  * this on login, signup, or anywhere the user becomes known.
140
374
  *
141
- * No-op if no cref was captured. Errors are reported through
142
- * `config.onError` by default — attribution is best-effort from the
143
- * browser — but you can await this and try/catch if you want to
144
- * surface failures explicitly.
375
+ * No-op if no cref was captured, or consent isn't granted. Errors are
376
+ * reported through `config.onError` by default — attribution is
377
+ * best-effort from the browser — but you can await this and try/catch
378
+ * if you want to surface failures explicitly.
145
379
  */
146
380
  async identify(userId) {
147
381
  const cref = this.getCref();