@drawbridge/drawbridge-utils 0.0.135 → 0.0.136

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/pricing.js CHANGED
@@ -1,746 +1,746 @@
1
- // lib/features.js
2
- var page = {
3
- qrcode: {
4
- key: "page:qrcode",
5
- error: "Plan does not include qrcodes",
6
- feature: "Page qrcode management"
7
- },
8
- shortcode: {
9
- key: "page:shortcode",
10
- error: "Plan does not include shortcodes",
11
- feature: "Page shortcode management"
12
- },
13
- slug: {
14
- key: "page:slug",
15
- error: "Plan does not include url customization",
16
- feature: "Page slug customization"
17
- }
1
+ // lib/transactions.js
2
+ import { currentTraceId } from "@drawbridge/drawbridge-telemetry";
3
+ var insertTransaction = async ({
4
+ db,
5
+ user,
6
+ type,
7
+ category,
8
+ source,
9
+ amount,
10
+ _id,
11
+ stripeInvoiceId,
12
+ stripeEventId,
13
+ session,
14
+ ...rest
15
+ }) => {
16
+ const trace = currentTraceId();
17
+ await db.create({
18
+ authenticated: user,
19
+ collection: "transaction",
20
+ data: {
21
+ ..._id && { _id },
22
+ user: user == null ? void 0 : user.id,
23
+ type,
24
+ category,
25
+ source,
26
+ amount,
27
+ ...trace && { trace },
28
+ ...rest,
29
+ ...stripeInvoiceId && { stripeInvoiceId },
30
+ ...stripeEventId && { stripeEventId }
31
+ },
32
+ ...session && { options: { session } }
33
+ });
18
34
  };
19
- var fields = {
20
- additional: {
21
- key: "campaign:fields:additional",
22
- error: "Plan does not include additional fields",
23
- feature: "Campaign additional fields"
24
- },
25
- lead: {
26
- key: "campaign:fields:lead",
27
- error: "Plan does not include lead fields",
28
- feature: "Campaign lead fields"
35
+ var debit = async ({
36
+ db,
37
+ user,
38
+ amount,
39
+ type,
40
+ category,
41
+ source,
42
+ stripeInvoiceId,
43
+ stripeEventId,
44
+ session,
45
+ ...rest
46
+ }) => {
47
+ if (!Number.isInteger(amount) || amount <= 0) {
48
+ throw new Error(`debit() requires a positive integer amount, got ${amount}`);
29
49
  }
30
- };
31
- var field = {
32
- email: {
33
- key: "campaign:field:email",
34
- error: "Plan does not include email field",
35
- feature: "Campaign email field"
36
- },
37
- name: {
38
- key: "campaign:field:name",
39
- error: "Plan does not include name field",
40
- feature: "Campaign name field"
41
- },
42
- number: {
43
- key: "campaign:field:number",
44
- error: "Plan does not include number field",
45
- feature: "Campaign number field"
46
- },
47
- phone: {
48
- key: "campaign:field:phone",
49
- error: "Plan does not include phone field",
50
- feature: "Campaign phone field"
51
- },
52
- select: {
53
- key: "campaign:field:select",
54
- error: "Plan does not include dropdown field",
55
- feature: "Campaign dropdown field"
56
- },
57
- text: {
58
- key: "campaign:field:text",
59
- error: "Plan does not include short text field",
60
- feature: "Campaign short text field"
61
- },
62
- textarea: {
63
- key: "campaign:field:textarea",
64
- error: "Plan does not include long text field",
65
- feature: "Campaign long text field"
50
+ if (!type) {
51
+ throw new Error('debit() requires a type (e.g., "ai")');
66
52
  }
67
- };
68
- var connection = {
69
- attentive: {
70
- key: "organization:connection:attentive",
71
- error: "Plan does not include Attentive connection",
72
- feature: "Attentive connection"
73
- },
74
- // Klaviyo shipped without an entry here, which meant no plan GRANTED its
75
- // key and the feature gate denied every non-admin request — a latent 403
76
- // found while adding Attentive. getPlanFeature answers granted:false for a
77
- // key absent from the plan's map, so a manifest feature key that appears in
78
- // no plan list is a connection only admins can manage.
79
- klaviyo: {
80
- key: "organization:connection:klaviyo",
81
- error: "Plan does not include Klaviyo connection",
82
- feature: "Klaviyo connection"
83
- },
84
- mailchimp: {
85
- key: "organization:connection:mailchimp",
86
- error: "Plan does not include Mailchimp connection",
87
- feature: "Mailchimp connection"
88
- },
89
- sendgrid: {
90
- key: "organization:connection:sendgrid",
91
- error: "Plan does not include SendGrid connection",
92
- feature: "SendGrid connection"
93
- },
94
- shopify: {
95
- key: "organization:connection:shopify",
96
- error: "Plan does not include Shopify connection",
97
- feature: "Shopify connection"
98
- },
99
- twilio: {
100
- key: "organization:connection:twilio",
101
- error: "Plan does not include Twilio connection",
102
- feature: "Twilio connection"
103
- },
104
- webhook: {
105
- key: "organization:connection:webhook",
106
- error: "Plan does not include Webhook connection",
107
- feature: "Webhook connection"
53
+ if (!source) {
54
+ throw new Error('debit() requires a source ("system" | "admin" | "user")');
108
55
  }
56
+ await insertTransaction({
57
+ db,
58
+ user,
59
+ type,
60
+ category,
61
+ source,
62
+ amount: -amount,
63
+ stripeInvoiceId,
64
+ stripeEventId,
65
+ session,
66
+ ...rest
67
+ });
109
68
  };
110
- var organization = {
111
- advertisements: {
112
- key: "organization:advertisements",
113
- error: "Your plan does not include advertisements",
114
- feature: "Organization advertisement management"
115
- },
116
- affiliates: {
117
- key: "organization:affiliates",
118
- error: "Your plan does not include affiliates",
119
- feature: "Organization affiliates management"
120
- },
121
- analytics: {
122
- key: "organization:analytics",
123
- error: "Your plan does not include analytics",
124
- feature: "Organization analytics management"
125
- },
126
- brands: {
127
- key: "organization:brands",
128
- error: "Your plan does not include brands",
129
- feature: "Organization brands management"
69
+
70
+ // lib/billing.js
71
+ import { createLogger } from "@drawbridge/drawbridge-telemetry";
72
+ var logger = createLogger();
73
+ var MARKUP = 1.3;
74
+ var cost = {
75
+ // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
76
+ // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
77
+ // output). ~3.6x the retired 2.5-flash output rate.
78
+ "gemini-3.5-flash": {
79
+ cached: 15,
80
+ input: 150,
81
+ output: 900
130
82
  },
131
- // Gates the Networking section as a whole — a verified sending domain today,
132
- // the SMS number and a custom page domain as they land. One key rather than
133
- // one per type: they are the same capability to a merchant, and splitting
134
- // them would mean a plan could grant half a section.
135
- //
136
- // It replaces `connection.sender`, which named a connection this stopped
137
- // being. Free organizations cannot send lead-facing email at all, so a
138
- // sending identity there is one they could never send from.
139
- networking: {
140
- key: "organization:networking",
141
- error: "Your plan does not include a custom sending identity",
142
- feature: "Organization networking"
83
+ // gemini-3.5-flash-lite verified against Google's pricing page 2026-08-20:
84
+ // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
85
+ // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
86
+ // ranks the feed on this tier one call per page — so its rows were the
87
+ // unpriced ones until now.
88
+ "gemini-3.5-flash-lite": {
89
+ cached: 3,
90
+ input: 30,
91
+ output: 250
143
92
  },
144
- members: {
145
- key: "organization:members",
146
- error: "Your plan does not include team members",
147
- feature: "Organization members management"
93
+ "gemini-2.5-flash": {
94
+ cached: 3,
95
+ input: 30,
96
+ output: 250
148
97
  },
149
- reports: {
150
- key: "organization:report",
151
- error: "Plan does not include report generation",
152
- feature: "Organization report generation"
98
+ "gemini-2.5-flash-image": {
99
+ cached: 3,
100
+ input: 30,
101
+ output: 3e3
153
102
  },
154
- subdomain: {
155
- key: "organization:subdomain",
156
- error: "Plan does not include subdomain customization",
157
- feature: "Organization subdomain customization"
103
+ // gemini-3-pro-image-preview — verified against Google's pricing page
104
+ // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
105
+ // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
106
+ // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
107
+ // that reproduces the per-image price from the tokens usageMetadata
108
+ // reports. Growth's hero generation runs this model today.
109
+ "gemini-3-pro-image-preview": {
110
+ cached: 20,
111
+ input: 200,
112
+ output: 12e3
158
113
  }
159
114
  };
160
-
161
- // index.js
162
- import { code, data } from "currency-codes";
163
- import { customAlphabet } from "nanoid";
164
-
165
- // lib/color.js
166
- import tinycolor from "tinycolor2";
167
- var colorFormatted = (value) => {
168
- const color = tinycolor(value);
169
- const attributes = {
170
- brightness: color.getBrightness(),
171
- dark: color.isDark(),
172
- light: color.isLight(),
173
- luminance: color.getLuminance()
174
- };
175
- return {
176
- attributes,
177
- hex: color.toHexString(),
178
- hsl: color.toHsl(),
179
- hsv: color.toHsv(),
180
- rgb: color.toRgbString()
181
- };
115
+ var toolCost = {
116
+ search: 3.5
182
117
  };
183
- var colorAccessible = (background2) => {
184
- const white = "#ffffff";
185
- const black = "#000000";
186
- return tinycolor.isReadable(
187
- background2,
188
- white,
118
+ var toolPricing = Object.fromEntries(
119
+ Object.entries(toolCost).map(([tool, value]) => [
120
+ tool,
121
+ Math.ceil(value * MARKUP)
122
+ ])
123
+ );
124
+ var pricing = Object.fromEntries(
125
+ Object.entries(cost).map(([model, rates]) => [
126
+ model,
189
127
  {
190
- level: "AA",
191
- size: "normal"
128
+ cached: Math.round(rates.cached * MARKUP),
129
+ input: Math.round(rates.input * MARKUP),
130
+ output: Math.round(rates.output * MARKUP)
192
131
  }
193
- ) ? white : black;
132
+ ])
133
+ );
134
+ var sumCents = (rates, tokens) => Math.ceil(
135
+ (Number((tokens == null ? void 0 : tokens.cached) || 0) * rates.cached + Number((tokens == null ? void 0 : tokens.input) || 0) * rates.input + (Number((tokens == null ? void 0 : tokens.output) || 0) + Number((tokens == null ? void 0 : tokens.thinking) || 0)) * rates.output) / 1e6
136
+ );
137
+ var sumToolCents = (table, tools) => Object.entries(tools || {}).reduce(
138
+ (total, [name, used]) => total + (used && table[name] ? table[name] : 0),
139
+ 0
140
+ );
141
+ var priceForRequest = (model, usage, tools) => {
142
+ const rates = pricing[model];
143
+ if (!rates) {
144
+ throw new Error(`Unknown model for pricing: ${model}`);
145
+ }
146
+ return sumCents(rates, usage) + Math.ceil(sumToolCents(toolPricing, tools));
194
147
  };
195
-
196
- // lib/constants.js
197
- var font = {
198
- family: "Roboto Flex",
199
- transform: "none",
200
- weight: "regular"
148
+ var costForRequest = (model, usage, tools) => {
149
+ const rates = cost[model];
150
+ if (!rates) {
151
+ throw new Error(`Unknown model for cost: ${model}`);
152
+ }
153
+ return sumCents(rates, usage) + Math.ceil(sumToolCents(toolCost, tools));
201
154
  };
202
- var background = "#ffffff";
203
- var style = {
204
- background: {
205
- color: colorFormatted(background)
206
- },
207
- body: font,
208
- button: {
209
- background: {
210
- color: colorFormatted(background)
211
- },
212
- radius: 0,
213
- text: {
214
- color: colorFormatted(colorAccessible(background))
155
+ var billRequest = async ({ db, user, model, usage, tools }) => {
156
+ const gross = priceForRequest(model, usage, tools);
157
+ const cogs = costForRequest(model, usage, tools);
158
+ await debit({
159
+ db,
160
+ user,
161
+ amount: gross,
162
+ type: "ai",
163
+ category: "usage",
164
+ source: "user",
165
+ ai: {
166
+ name: "google",
167
+ model,
168
+ tokens: usage,
169
+ tools,
170
+ rates: {
171
+ wholesale: cost[model],
172
+ retail: pricing[model]
173
+ },
174
+ totals: {
175
+ cost: cogs,
176
+ net: gross - cogs,
177
+ gross
178
+ }
215
179
  }
216
- },
217
- heading: font,
218
- input: {
219
- radius: 0
220
- },
221
- text: {
222
- color: colorFormatted(colorAccessible(background))
180
+ });
181
+ };
182
+ var ai = {
183
+ // The rate tables themselves, in cents per 1,000,000 tokens (`tools` in cents
184
+ // per request). Read-only surface for lib/pricing.js — the fee math above is
185
+ // still the only thing that should compute from them.
186
+ rates: { wholesale: cost, retail: pricing, tools: { wholesale: toolCost, retail: toolPricing } },
187
+ // Retail cents for a request (what the user pays).
188
+ price: priceForRequest,
189
+ // Wholesale cents for a request (what we pay the provider).
190
+ cost: costForRequest,
191
+ // Deferred bill() the caller fires on success, so the debit lands only when
192
+ // the request succeeds and bundles into that request's Sentry trace. No user
193
+ // → a no-op.
194
+ bill: ({ db, user, model, usage, tools }) => (user == null ? void 0 : user.id) ? () => billRequest({ db, user, model, usage, tools }) : () => Promise.resolve()
195
+ };
196
+ var STANDARD_RATE_PER_GB = 8;
197
+ var PREMIUM_RATE_PER_GB = 11;
198
+ var STANDARD_CPM_CENTS = 0.15;
199
+ var PREMIUM_CPM_CENTS = 0.25;
200
+ var PREMIUM_HOSTS = /* @__PURE__ */ new Set();
201
+ var LOCAL_FLAT_CENTS = 1;
202
+ var REUSE_FLAT_CENTS = 1;
203
+ var isPremium = (url) => {
204
+ try {
205
+ return PREMIUM_HOSTS.has(new URL(url).hostname);
206
+ } catch {
207
+ return false;
223
208
  }
224
209
  };
225
-
226
- // index.js
227
- var nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 8);
228
- var infinite = 1e300;
229
- var megabyte = 1024 * 1024;
230
- var gigabyte = megabyte * 1024;
231
- var currencies = data.map((item) => ({
232
- ...item,
233
- key: item.currency,
234
- value: item.code
235
- }));
236
-
237
- // lib/plans.js
238
- var featuresFor = (array = []) => Object.values({
239
- ...connection,
240
- ...organization,
241
- ...fields,
242
- ...field,
243
- ...page
244
- }).reduce(
245
- (accumulator, { key, error, feature }) => {
246
- if (array.includes(key)) {
247
- accumulator.granted[key] = feature;
248
- } else {
249
- accumulator.denied[key] = error;
210
+ var ratePerGB = (url) => isPremium(url) ? PREMIUM_RATE_PER_GB : STANDARD_RATE_PER_GB;
211
+ var cpmCents = (url) => isPremium(url) ? PREMIUM_CPM_CENTS : STANDARD_CPM_CENTS;
212
+ var scrapeFee = ({ provider, bytes, requests, url, cold }) => {
213
+ if (!cold) return REUSE_FLAT_CENTS;
214
+ if (provider === "brightdata") {
215
+ return Math.ceil((bytes || 0) / 1e9 * ratePerGB(url) * 100 * MARKUP);
216
+ }
217
+ if (provider === "brightdata-unlocker") {
218
+ return Math.ceil((requests || 0) * cpmCents(url) * MARKUP);
219
+ }
220
+ return LOCAL_FLAT_CENTS;
221
+ };
222
+ var scrapeBreakdown = ({ provider, bytes, requests, url }) => {
223
+ if (provider === "brightdata") {
224
+ return {
225
+ provider,
226
+ basis: "bytes",
227
+ units: bytes || 0,
228
+ rate: ratePerGB(url),
229
+ rateUnit: "GB",
230
+ cents: scrapeFee({ provider, bytes, url, cold: true })
231
+ };
232
+ }
233
+ if (provider === "brightdata-unlocker") {
234
+ return {
235
+ provider,
236
+ basis: "requests",
237
+ units: requests || 0,
238
+ // cpmCents is cents-per-request; ×10 expresses it as dollars per 1,000.
239
+ rate: cpmCents(url) * 10,
240
+ rateUnit: "1K requests",
241
+ cents: scrapeFee({ provider, requests, url, cold: true })
242
+ };
243
+ }
244
+ return { provider: provider || "local", basis: "local", units: null, rate: null, rateUnit: null, cents: LOCAL_FLAT_CENTS };
245
+ };
246
+ var scrape = {
247
+ // Cents for a scrape given its provider/bytes/cold basis.
248
+ fee: scrapeFee,
249
+ // Itemized cold-fee breakdown (basis/units/rate/cents) for display + the scrape doc.
250
+ breakdown: scrapeBreakdown,
251
+ // Deferred bill(scrapeId) the scrape store's resolve() hands the generation
252
+ // flow. Atomically claims the cold (first-time) fee via the `charged` flag so
253
+ // a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
254
+ // flat rate. Best-effort — a billing hiccup never fails generation.
255
+ bill: ({ controller, user, page: page2, url }) => async (scrapeId) => {
256
+ var _a;
257
+ if (!(user == null ? void 0 : user.id) || !scrapeId) return;
258
+ try {
259
+ const claim = await controller.update({
260
+ collection: "scrape",
261
+ data: { $set: { charged: true } },
262
+ options: { bypassDocumentValidation: true },
263
+ query: { id: scrapeId, charged: { $ne: true } }
264
+ });
265
+ const cents = scrapeFee({
266
+ provider: (_a = page2 == null ? void 0 : page2.provider) == null ? void 0 : _a.name,
267
+ bytes: page2 == null ? void 0 : page2.bytes,
268
+ requests: page2 == null ? void 0 : page2.requests,
269
+ url: (page2 == null ? void 0 : page2.url) || url,
270
+ cold: Boolean(claim == null ? void 0 : claim.value)
271
+ });
272
+ if (cents > 0) await debit({ db: controller, user, amount: cents, type: "ai", category: "usage", source: "user" });
273
+ } catch (error) {
274
+ logger.error(error, { extra: { action: "generation.scrape.bill", scrapeId } });
250
275
  }
251
- return accumulator;
276
+ }
277
+ };
278
+
279
+ // lib/features.js
280
+ var page = {
281
+ qrcode: {
282
+ key: "page:qrcode",
283
+ error: "Plan does not include qrcodes",
284
+ feature: "Page qrcode management"
252
285
  },
253
- { denied: {}, granted: {} }
254
- );
255
- var unitAmountDecimal = (cents) => {
256
- const value = Number(cents);
257
- if (cents == null || !Number.isFinite(value)) throw new Error(`Invalid overage rate: ${cents}`);
258
- return String(value);
286
+ shortcode: {
287
+ key: "page:shortcode",
288
+ error: "Plan does not include shortcodes",
289
+ feature: "Page shortcode management"
290
+ },
291
+ slug: {
292
+ key: "page:slug",
293
+ error: "Plan does not include url customization",
294
+ feature: "Page slug customization"
295
+ }
259
296
  };
260
- var all = {
261
- features: (array = []) => featuresFor([
262
- connection.attentive.key,
263
- connection.klaviyo.key,
264
- connection.mailchimp.key,
265
- connection.sendgrid.key,
266
- connection.shopify.key,
267
- connection.twilio.key,
268
- connection.webhook.key,
269
- organization.affiliates.key,
270
- organization.brands.key,
271
- fields.additional.key,
272
- fields.lead.key,
273
- field.email.key,
274
- field.name.key,
275
- field.number.key,
276
- field.phone.key,
277
- field.select.key,
278
- field.text.key,
279
- field.textarea.key,
280
- page.qrcode.key,
281
- page.shortcode.key,
282
- ...array
283
- ]),
284
- // `members` and `storage` default to infinite so an unnamed term on a custom
285
- // plan reads as UNLIMITED rather than absent. Storage used to have no
286
- // default, so a deal that did not name it resolved to undefined and the
287
- // organization's usage card simply omitted the row — the same blank field
288
- // that showed "Unlimited" for members showed nothing at all for storage.
289
- // Every catalog plan names both, so the defaults only ever apply to a
290
- // custom plan. `actions` has no default on purpose: an unnamed allowance
291
- // bills nothing, which is why the availability switch refuses to flip
292
- // without one.
293
- limits: ({ actions, members = infinite, storage = infinite }) => ({
294
- campaign: {
295
- advertisements: infinite,
296
- links: infinite,
297
- fields: infinite,
298
- pages: infinite
299
- },
300
- organization: {
301
- actions,
302
- affiliates: infinite,
303
- brands: infinite,
304
- campaigns: infinite,
305
- members,
306
- storage
307
- }
308
- })
309
- };
310
- var free = {
311
- conversion: 3,
312
- features: all.features(),
313
- limits: all.limits({
314
- actions: 200,
315
- members: 0,
316
- storage: gigabyte * 5
317
- }),
318
- title: "Free"
297
+ var fields = {
298
+ additional: {
299
+ key: "campaign:fields:additional",
300
+ error: "Plan does not include additional fields",
301
+ feature: "Campaign additional fields"
302
+ },
303
+ lead: {
304
+ key: "campaign:fields:lead",
305
+ error: "Plan does not include lead fields",
306
+ feature: "Campaign lead fields"
307
+ }
319
308
  };
320
- var plans = {
321
- DB00002: {
322
- // A verified sending domain is a PAID capability: free plans cannot send
323
- // lead-facing email at all (the send path gates on an active
324
- // subscription), so granting it there would offer a domain that can
325
- // never send from.
326
- features: all.features([organization.networking.key, organization.members.key]),
327
- limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
328
- marketing: {
329
- description: "Tools to fine-tune campaigns and improve lead quality.",
330
- features: [],
331
- limits: [
332
- ["Actions per month", "5,000"],
333
- ["Affiliates", "Unlimited"],
334
- ["Brands", "Unlimited"],
335
- ["Campaigns", "Unlimited"],
336
- ["Pages", "Unlimited"],
337
- ["Members", "3"],
338
- ["Storage", "10GB"]
339
- ]
340
- },
341
- actionCents: 2.5,
342
- title: "Starter",
343
- conversion: 2
309
+ var field = {
310
+ email: {
311
+ key: "campaign:field:email",
312
+ error: "Plan does not include email field",
313
+ feature: "Campaign email field"
344
314
  },
345
- DB00003: {
346
- features: all.features([
347
- organization.networking.key,
348
- organization.advertisements.key,
349
- organization.analytics.key,
350
- organization.members.key,
351
- organization.subdomain.key,
352
- page.slug.key
353
- ]),
354
- limits: all.limits({ actions: 15e3, members: 5, storage: gigabyte * 20 }),
355
- marketing: {
356
- description: "Expand your reach and grow your lead pipeline.",
357
- features: [
358
- "Analytics",
359
- "Custom subdomain / URLs",
360
- "Confirmation page ads"
361
- ],
362
- limits: [
363
- ["Actions per month", "15,000"],
364
- ["Affiliates", "Unlimited"],
365
- ["Brands", "Unlimited"],
366
- ["Campaigns", "Unlimited"],
367
- ["Pages", "Unlimited"],
368
- ["Members", "5"],
369
- ["Storage", "20GB"]
370
- ]
371
- },
372
- actionCents: 2,
373
- title: "Pro",
374
- conversion: 1.5
315
+ name: {
316
+ key: "campaign:field:name",
317
+ error: "Plan does not include name field",
318
+ feature: "Campaign name field"
375
319
  },
376
- DB00004: {
377
- features: all.features([
378
- organization.networking.key,
379
- organization.advertisements.key,
380
- organization.analytics.key,
381
- organization.members.key,
382
- organization.subdomain.key,
383
- page.slug.key
384
- ]),
385
- limits: all.limits({ actions: 4e4, members: 10, storage: gigabyte * 50 }),
386
- marketing: {
387
- description: "Accelerate acquisition with more power and flexibility.",
388
- features: [
389
- "Analytics",
390
- "Custom subdomain / URLs",
391
- "Confirmation page ads"
392
- ],
393
- limits: [
394
- ["Actions per month", "40,000"],
395
- ["Affiliates", "Unlimited"],
396
- ["Brands", "Unlimited"],
397
- ["Campaigns", "Unlimited"],
398
- ["Pages", "Unlimited"],
399
- ["Members", "10"],
400
- ["Storage", "50GB"]
401
- ]
402
- },
403
- actionCents: 1.85,
404
- title: "Premium",
405
- conversion: 1
320
+ number: {
321
+ key: "campaign:field:number",
322
+ error: "Plan does not include number field",
323
+ feature: "Campaign number field"
406
324
  },
407
- DB00005: {
408
- features: all.features([
409
- organization.networking.key,
410
- organization.advertisements.key,
411
- organization.analytics.key,
412
- organization.members.key,
413
- organization.subdomain.key,
414
- page.slug.key
415
- ]),
416
- limits: all.limits({ actions: 1e5, members: infinite, storage: gigabyte * 100 }),
417
- marketing: {
418
- description: "Built for brands focused on results.",
419
- features: [
420
- "Analytics",
421
- "Custom subdomain / URLs",
422
- "Confirmation page ads"
423
- ],
424
- limits: [
425
- ["Actions per month", "100,000"],
426
- ["Affiliates", "Unlimited"],
427
- ["Brands", "Unlimited"],
428
- ["Campaigns", "Unlimited"],
429
- ["Pages", "Unlimited"],
430
- ["Members", "Unlimited"],
431
- ["Storage", "100GB"]
432
- ]
433
- },
434
- actionCents: 1.5,
435
- title: "Elite",
436
- conversion: 0.5
325
+ phone: {
326
+ key: "campaign:field:phone",
327
+ error: "Plan does not include phone field",
328
+ feature: "Campaign phone field"
329
+ },
330
+ select: {
331
+ key: "campaign:field:select",
332
+ error: "Plan does not include dropdown field",
333
+ feature: "Campaign dropdown field"
334
+ },
335
+ text: {
336
+ key: "campaign:field:text",
337
+ error: "Plan does not include short text field",
338
+ feature: "Campaign short text field"
339
+ },
340
+ textarea: {
341
+ key: "campaign:field:textarea",
342
+ error: "Plan does not include long text field",
343
+ feature: "Campaign long text field"
437
344
  }
438
345
  };
439
- var resolvePlan = (subscription) => {
440
- var _a, _b;
441
- const custom = subscription == null ? void 0 : subscription.custom;
442
- if (!custom) return plans[subscription == null ? void 0 : subscription.plan] ?? free;
443
- return {
444
- // Reusing all.features / all.limits is what keeps a custom plan the same
445
- // SHAPE as a catalog one: the base feature grants every plan carries, the
446
- // campaign limits that are always infinite, and members defaulting to
447
- // infinite when a deal does not name it.
448
- conversion: custom.conversion ?? free.conversion,
449
- custom: true,
450
- // A custom plan is a negotiated PAID deal, so it carries the paid-tier
451
- // baseline whether or not the deal thought to name it. Today that is the
452
- // sending domain: every catalog paid tier grants it, and a custom plan
453
- // silently lacking it would be a support ticket, not a pricing decision.
454
- features: all.features([organization.networking.key, ...((_a = custom.features) == null ? void 0 : _a.granted) || []]),
455
- limits: all.limits(((_b = custom.limits) == null ? void 0 : _b.organization) || {}),
456
- // A custom plan stores its overage BARE on `custom.overages`. Number() so
457
- // a deal stored as a string still resolves to cents-per-action; an unnamed
458
- // overage stays undefined (it bills nothing) rather than becoming NaN.
459
- actionCents: custom.overages == null ? void 0 : Number(custom.overages),
460
- title: custom.title || "Custom"
461
- };
462
- };
463
- var conversionRate = (subscription) => {
464
- var _a;
465
- return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
466
- };
467
-
468
- // lib/transactions.js
469
- import { currentTraceId } from "@drawbridge/drawbridge-telemetry";
470
- var insertTransaction = async ({
471
- db,
472
- user,
473
- type,
474
- category,
475
- source,
476
- amount,
477
- _id,
478
- stripeInvoiceId,
479
- stripeEventId,
480
- session,
481
- ...rest
482
- }) => {
483
- const trace = currentTraceId();
484
- await db.create({
485
- authenticated: user,
486
- collection: "transaction",
487
- data: {
488
- ..._id && { _id },
489
- user: user == null ? void 0 : user.id,
490
- type,
491
- category,
492
- source,
493
- amount,
494
- ...trace && { trace },
495
- ...rest,
496
- ...stripeInvoiceId && { stripeInvoiceId },
497
- ...stripeEventId && { stripeEventId }
498
- },
499
- ...session && { options: { session } }
500
- });
501
- };
502
- var debit = async ({
503
- db,
504
- user,
505
- amount,
506
- type,
507
- category,
508
- source,
509
- stripeInvoiceId,
510
- stripeEventId,
511
- session,
512
- ...rest
513
- }) => {
514
- if (!Number.isInteger(amount) || amount <= 0) {
515
- throw new Error(`debit() requires a positive integer amount, got ${amount}`);
516
- }
517
- if (!type) {
518
- throw new Error('debit() requires a type (e.g., "ai")');
519
- }
520
- if (!source) {
521
- throw new Error('debit() requires a source ("system" | "admin" | "user")');
346
+ var connection = {
347
+ attentive: {
348
+ key: "organization:connection:attentive",
349
+ error: "Plan does not include Attentive connection",
350
+ feature: "Attentive connection"
351
+ },
352
+ // Klaviyo shipped without an entry here, which meant no plan GRANTED its
353
+ // key and the feature gate denied every non-admin request — a latent 403
354
+ // found while adding Attentive. getPlanFeature answers granted:false for a
355
+ // key absent from the plan's map, so a manifest feature key that appears in
356
+ // no plan list is a connection only admins can manage.
357
+ klaviyo: {
358
+ key: "organization:connection:klaviyo",
359
+ error: "Plan does not include Klaviyo connection",
360
+ feature: "Klaviyo connection"
361
+ },
362
+ mailchimp: {
363
+ key: "organization:connection:mailchimp",
364
+ error: "Plan does not include Mailchimp connection",
365
+ feature: "Mailchimp connection"
366
+ },
367
+ sendgrid: {
368
+ key: "organization:connection:sendgrid",
369
+ error: "Plan does not include SendGrid connection",
370
+ feature: "SendGrid connection"
371
+ },
372
+ shopify: {
373
+ key: "organization:connection:shopify",
374
+ error: "Plan does not include Shopify connection",
375
+ feature: "Shopify connection"
376
+ },
377
+ twilio: {
378
+ key: "organization:connection:twilio",
379
+ error: "Plan does not include Twilio connection",
380
+ feature: "Twilio connection"
381
+ },
382
+ webhook: {
383
+ key: "organization:connection:webhook",
384
+ error: "Plan does not include Webhook connection",
385
+ feature: "Webhook connection"
522
386
  }
523
- await insertTransaction({
524
- db,
525
- user,
526
- type,
527
- category,
528
- source,
529
- amount: -amount,
530
- stripeInvoiceId,
531
- stripeEventId,
532
- session,
533
- ...rest
534
- });
535
387
  };
536
-
537
- // lib/billing.js
538
- import { createLogger } from "@drawbridge/drawbridge-telemetry";
539
- var logger = createLogger();
540
- var MARKUP = 1.3;
541
- var cost = {
542
- // gemini-3.5-flash — verified against Google's pricing page 2026-07-09:
543
- // $0.15 cached / $1.50 input / $9.00 output per 1M tokens (thinking billed at
544
- // output). ~3.6x the retired 2.5-flash output rate.
545
- "gemini-3.5-flash": {
546
- cached: 15,
547
- input: 150,
548
- output: 900
388
+ var organization = {
389
+ advertisements: {
390
+ key: "organization:advertisements",
391
+ error: "Your plan does not include advertisements",
392
+ feature: "Organization advertisement management"
393
+ },
394
+ affiliates: {
395
+ key: "organization:affiliates",
396
+ error: "Your plan does not include affiliates",
397
+ feature: "Organization affiliates management"
549
398
  },
550
- // gemini-3.5-flash-lite — verified against Google's pricing page 2026-08-20:
551
- // $0.03 cached / $0.30 input / $2.50 output per 1M tokens (thinking billed at
552
- // output). A fifth of flash on input, ~a quarter on output. Growth's assistant
553
- // ranks the feed on this tier — one call per page — so its rows were the
554
- // unpriced ones until now.
555
- "gemini-3.5-flash-lite": {
556
- cached: 3,
557
- input: 30,
558
- output: 250
399
+ analytics: {
400
+ key: "organization:analytics",
401
+ error: "Your plan does not include analytics",
402
+ feature: "Organization analytics management"
559
403
  },
560
- "gemini-2.5-flash": {
561
- cached: 3,
562
- input: 30,
563
- output: 250
404
+ brands: {
405
+ key: "organization:brands",
406
+ error: "Your plan does not include brands",
407
+ feature: "Organization brands management"
564
408
  },
565
- "gemini-2.5-flash-image": {
566
- cached: 3,
567
- input: 30,
568
- output: 3e3
409
+ // Gates the Networking section as a whole — a verified sending domain today,
410
+ // the SMS number and a custom page domain as they land. One key rather than
411
+ // one per type: they are the same capability to a merchant, and splitting
412
+ // them would mean a plan could grant half a section.
413
+ //
414
+ // It replaces `connection.sender`, which named a connection this stopped
415
+ // being. Free organizations cannot send lead-facing email at all, so a
416
+ // sending identity there is one they could never send from.
417
+ networking: {
418
+ key: "organization:networking",
419
+ error: "Your plan does not include a custom sending identity",
420
+ feature: "Organization networking"
569
421
  },
570
- // gemini-3-pro-image-preview — verified against Google's pricing page
571
- // 2026-08-12: $2.00 input / $12.00 text output per 1M, and image output
572
- // tokens at ~$120/1M (a 1K-2K image is 1120 tokens = $0.134, a 4K image
573
- // 2000 tokens = $0.24). Encoded the flash-image way: one flat output rate
574
- // that reproduces the per-image price from the tokens usageMetadata
575
- // reports. Growth's hero generation runs this model today.
576
- "gemini-3-pro-image-preview": {
577
- cached: 20,
578
- input: 200,
579
- output: 12e3
422
+ members: {
423
+ key: "organization:members",
424
+ error: "Your plan does not include team members",
425
+ feature: "Organization members management"
426
+ },
427
+ reports: {
428
+ key: "organization:report",
429
+ error: "Plan does not include report generation",
430
+ feature: "Organization report generation"
431
+ },
432
+ subdomain: {
433
+ key: "organization:subdomain",
434
+ error: "Plan does not include subdomain customization",
435
+ feature: "Organization subdomain customization"
580
436
  }
581
437
  };
582
- var toolCost = {
583
- search: 3.5
438
+
439
+ // index.js
440
+ import { code, data } from "currency-codes";
441
+ import { customAlphabet } from "nanoid";
442
+
443
+ // lib/color.js
444
+ import tinycolor from "tinycolor2";
445
+ var colorFormatted = (value) => {
446
+ const color = tinycolor(value);
447
+ const attributes = {
448
+ brightness: color.getBrightness(),
449
+ dark: color.isDark(),
450
+ light: color.isLight(),
451
+ luminance: color.getLuminance()
452
+ };
453
+ return {
454
+ attributes,
455
+ hex: color.toHexString(),
456
+ hsl: color.toHsl(),
457
+ hsv: color.toHsv(),
458
+ rgb: color.toRgbString()
459
+ };
584
460
  };
585
- var toolPricing = Object.fromEntries(
586
- Object.entries(toolCost).map(([tool, value]) => [
587
- tool,
588
- Math.ceil(value * MARKUP)
589
- ])
590
- );
591
- var pricing = Object.fromEntries(
592
- Object.entries(cost).map(([model, rates]) => [
593
- model,
461
+ var colorAccessible = (background2) => {
462
+ const white = "#ffffff";
463
+ const black = "#000000";
464
+ return tinycolor.isReadable(
465
+ background2,
466
+ white,
594
467
  {
595
- cached: Math.round(rates.cached * MARKUP),
596
- input: Math.round(rates.input * MARKUP),
597
- output: Math.round(rates.output * MARKUP)
468
+ level: "AA",
469
+ size: "normal"
598
470
  }
599
- ])
600
- );
601
- var sumCents = (rates, tokens) => Math.ceil(
602
- (Number((tokens == null ? void 0 : tokens.cached) || 0) * rates.cached + Number((tokens == null ? void 0 : tokens.input) || 0) * rates.input + (Number((tokens == null ? void 0 : tokens.output) || 0) + Number((tokens == null ? void 0 : tokens.thinking) || 0)) * rates.output) / 1e6
603
- );
604
- var sumToolCents = (table, tools) => Object.entries(tools || {}).reduce(
605
- (total, [name, used]) => total + (used && table[name] ? table[name] : 0),
606
- 0
607
- );
608
- var priceForRequest = (model, usage, tools) => {
609
- const rates = pricing[model];
610
- if (!rates) {
611
- throw new Error(`Unknown model for pricing: ${model}`);
612
- }
613
- return sumCents(rates, usage) + Math.ceil(sumToolCents(toolPricing, tools));
471
+ ) ? white : black;
614
472
  };
615
- var costForRequest = (model, usage, tools) => {
616
- const rates = cost[model];
617
- if (!rates) {
618
- throw new Error(`Unknown model for cost: ${model}`);
473
+
474
+ // lib/constants.js
475
+ var font = {
476
+ family: "Roboto Flex",
477
+ transform: "none",
478
+ weight: "regular"
479
+ };
480
+ var background = "#ffffff";
481
+ var style = {
482
+ background: {
483
+ color: colorFormatted(background)
484
+ },
485
+ body: font,
486
+ button: {
487
+ background: {
488
+ color: colorFormatted(background)
489
+ },
490
+ radius: 0,
491
+ text: {
492
+ color: colorFormatted(colorAccessible(background))
493
+ }
494
+ },
495
+ heading: font,
496
+ input: {
497
+ radius: 0
498
+ },
499
+ text: {
500
+ color: colorFormatted(colorAccessible(background))
619
501
  }
620
- return sumCents(rates, usage) + Math.ceil(sumToolCents(toolCost, tools));
621
502
  };
622
- var billRequest = async ({ db, user, model, usage, tools }) => {
623
- const gross = priceForRequest(model, usage, tools);
624
- const cogs = costForRequest(model, usage, tools);
625
- await debit({
626
- db,
627
- user,
628
- amount: gross,
629
- type: "ai",
630
- category: "usage",
631
- source: "user",
632
- ai: {
633
- name: "google",
634
- model,
635
- tokens: usage,
636
- tools,
637
- rates: {
638
- wholesale: cost[model],
639
- retail: pricing[model]
640
- },
641
- totals: {
642
- cost: cogs,
643
- net: gross - cogs,
644
- gross
645
- }
503
+
504
+ // index.js
505
+ var nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 8);
506
+ var infinite = 1e300;
507
+ var megabyte = 1024 * 1024;
508
+ var gigabyte = megabyte * 1024;
509
+ var currencies = data.map((item) => ({
510
+ ...item,
511
+ key: item.currency,
512
+ value: item.code
513
+ }));
514
+
515
+ // lib/plans.js
516
+ var featuresFor = (array = []) => Object.values({
517
+ ...connection,
518
+ ...organization,
519
+ ...fields,
520
+ ...field,
521
+ ...page
522
+ }).reduce(
523
+ (accumulator, { key, error, feature }) => {
524
+ if (array.includes(key)) {
525
+ accumulator.granted[key] = feature;
526
+ } else {
527
+ accumulator.denied[key] = error;
528
+ }
529
+ return accumulator;
530
+ },
531
+ { denied: {}, granted: {} }
532
+ );
533
+ var unitAmountDecimal = (cents) => {
534
+ const value = Number(cents);
535
+ if (cents == null || !Number.isFinite(value)) throw new Error(`Invalid overage rate: ${cents}`);
536
+ return String(value);
537
+ };
538
+ var all = {
539
+ features: (array = []) => featuresFor([
540
+ connection.attentive.key,
541
+ connection.klaviyo.key,
542
+ connection.mailchimp.key,
543
+ connection.sendgrid.key,
544
+ connection.shopify.key,
545
+ connection.twilio.key,
546
+ connection.webhook.key,
547
+ organization.affiliates.key,
548
+ organization.brands.key,
549
+ fields.additional.key,
550
+ fields.lead.key,
551
+ field.email.key,
552
+ field.name.key,
553
+ field.number.key,
554
+ field.phone.key,
555
+ field.select.key,
556
+ field.text.key,
557
+ field.textarea.key,
558
+ page.qrcode.key,
559
+ page.shortcode.key,
560
+ ...array
561
+ ]),
562
+ // `members` and `storage` default to infinite so an unnamed term on a custom
563
+ // plan reads as UNLIMITED rather than absent. Storage used to have no
564
+ // default, so a deal that did not name it resolved to undefined and the
565
+ // organization's usage card simply omitted the row — the same blank field
566
+ // that showed "Unlimited" for members showed nothing at all for storage.
567
+ // Every catalog plan names both, so the defaults only ever apply to a
568
+ // custom plan. `actions` has no default on purpose: an unnamed allowance
569
+ // bills nothing, which is why the availability switch refuses to flip
570
+ // without one.
571
+ limits: ({ actions, members = infinite, storage = infinite }) => ({
572
+ campaign: {
573
+ advertisements: infinite,
574
+ links: infinite,
575
+ fields: infinite,
576
+ pages: infinite
577
+ },
578
+ organization: {
579
+ actions,
580
+ affiliates: infinite,
581
+ brands: infinite,
582
+ campaigns: infinite,
583
+ members,
584
+ storage
646
585
  }
647
- });
648
- };
649
- var ai = {
650
- // The rate tables themselves, in cents per 1,000,000 tokens (`tools` in cents
651
- // per request). Read-only surface for lib/pricing.js — the fee math above is
652
- // still the only thing that should compute from them.
653
- rates: { wholesale: cost, retail: pricing, tools: { wholesale: toolCost, retail: toolPricing } },
654
- // Retail cents for a request (what the user pays).
655
- price: priceForRequest,
656
- // Wholesale cents for a request (what we pay the provider).
657
- cost: costForRequest,
658
- // Deferred bill() the caller fires on success, so the debit lands only when
659
- // the request succeeds and bundles into that request's Sentry trace. No user
660
- // → a no-op.
661
- bill: ({ db, user, model, usage, tools }) => (user == null ? void 0 : user.id) ? () => billRequest({ db, user, model, usage, tools }) : () => Promise.resolve()
586
+ })
662
587
  };
663
- var STANDARD_RATE_PER_GB = 8;
664
- var PREMIUM_RATE_PER_GB = 11;
665
- var STANDARD_CPM_CENTS = 0.15;
666
- var PREMIUM_CPM_CENTS = 0.25;
667
- var PREMIUM_HOSTS = /* @__PURE__ */ new Set();
668
- var LOCAL_FLAT_CENTS = 1;
669
- var REUSE_FLAT_CENTS = 1;
670
- var isPremium = (url) => {
671
- try {
672
- return PREMIUM_HOSTS.has(new URL(url).hostname);
673
- } catch {
674
- return false;
675
- }
588
+ var free = {
589
+ conversion: 3,
590
+ features: all.features(),
591
+ limits: all.limits({
592
+ actions: 200,
593
+ members: 0,
594
+ storage: gigabyte * 5
595
+ }),
596
+ title: "Free"
676
597
  };
677
- var ratePerGB = (url) => isPremium(url) ? PREMIUM_RATE_PER_GB : STANDARD_RATE_PER_GB;
678
- var cpmCents = (url) => isPremium(url) ? PREMIUM_CPM_CENTS : STANDARD_CPM_CENTS;
679
- var scrapeFee = ({ provider, bytes, requests, url, cold }) => {
680
- if (!cold) return REUSE_FLAT_CENTS;
681
- if (provider === "brightdata") {
682
- return Math.ceil((bytes || 0) / 1e9 * ratePerGB(url) * 100 * MARKUP);
683
- }
684
- if (provider === "brightdata-unlocker") {
685
- return Math.ceil((requests || 0) * cpmCents(url) * MARKUP);
598
+ var plans = {
599
+ DB00002: {
600
+ // A verified sending domain is a PAID capability: free plans cannot send
601
+ // lead-facing email at all (the send path gates on an active
602
+ // subscription), so granting it there would offer a domain that can
603
+ // never send from.
604
+ features: all.features([organization.networking.key, organization.members.key]),
605
+ limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
606
+ marketing: {
607
+ description: "Tools to fine-tune campaigns and improve lead quality.",
608
+ features: [],
609
+ limits: [
610
+ ["Actions per month", "5,000"],
611
+ ["Affiliates", "Unlimited"],
612
+ ["Brands", "Unlimited"],
613
+ ["Campaigns", "Unlimited"],
614
+ ["Pages", "Unlimited"],
615
+ ["Members", "3"],
616
+ ["Storage", "10GB"]
617
+ ]
618
+ },
619
+ actionCents: 2.5,
620
+ title: "Starter",
621
+ conversion: 2
622
+ },
623
+ DB00003: {
624
+ features: all.features([
625
+ organization.networking.key,
626
+ organization.advertisements.key,
627
+ organization.analytics.key,
628
+ organization.members.key,
629
+ organization.subdomain.key,
630
+ page.slug.key
631
+ ]),
632
+ limits: all.limits({ actions: 15e3, members: 5, storage: gigabyte * 20 }),
633
+ marketing: {
634
+ description: "Expand your reach and grow your lead pipeline.",
635
+ features: [
636
+ "Analytics",
637
+ "Custom subdomain / URLs",
638
+ "Confirmation page ads"
639
+ ],
640
+ limits: [
641
+ ["Actions per month", "15,000"],
642
+ ["Affiliates", "Unlimited"],
643
+ ["Brands", "Unlimited"],
644
+ ["Campaigns", "Unlimited"],
645
+ ["Pages", "Unlimited"],
646
+ ["Members", "5"],
647
+ ["Storage", "20GB"]
648
+ ]
649
+ },
650
+ actionCents: 2,
651
+ title: "Pro",
652
+ conversion: 1.5
653
+ },
654
+ DB00004: {
655
+ features: all.features([
656
+ organization.networking.key,
657
+ organization.advertisements.key,
658
+ organization.analytics.key,
659
+ organization.members.key,
660
+ organization.subdomain.key,
661
+ page.slug.key
662
+ ]),
663
+ limits: all.limits({ actions: 4e4, members: 10, storage: gigabyte * 50 }),
664
+ marketing: {
665
+ description: "Accelerate acquisition with more power and flexibility.",
666
+ features: [
667
+ "Analytics",
668
+ "Custom subdomain / URLs",
669
+ "Confirmation page ads"
670
+ ],
671
+ limits: [
672
+ ["Actions per month", "40,000"],
673
+ ["Affiliates", "Unlimited"],
674
+ ["Brands", "Unlimited"],
675
+ ["Campaigns", "Unlimited"],
676
+ ["Pages", "Unlimited"],
677
+ ["Members", "10"],
678
+ ["Storage", "50GB"]
679
+ ]
680
+ },
681
+ actionCents: 1.85,
682
+ title: "Premium",
683
+ conversion: 1
684
+ },
685
+ DB00005: {
686
+ features: all.features([
687
+ organization.networking.key,
688
+ organization.advertisements.key,
689
+ organization.analytics.key,
690
+ organization.members.key,
691
+ organization.subdomain.key,
692
+ page.slug.key
693
+ ]),
694
+ limits: all.limits({ actions: 1e5, members: infinite, storage: gigabyte * 100 }),
695
+ marketing: {
696
+ description: "Built for brands focused on results.",
697
+ features: [
698
+ "Analytics",
699
+ "Custom subdomain / URLs",
700
+ "Confirmation page ads"
701
+ ],
702
+ limits: [
703
+ ["Actions per month", "100,000"],
704
+ ["Affiliates", "Unlimited"],
705
+ ["Brands", "Unlimited"],
706
+ ["Campaigns", "Unlimited"],
707
+ ["Pages", "Unlimited"],
708
+ ["Members", "Unlimited"],
709
+ ["Storage", "100GB"]
710
+ ]
711
+ },
712
+ actionCents: 1.5,
713
+ title: "Elite",
714
+ conversion: 0.5
686
715
  }
687
- return LOCAL_FLAT_CENTS;
688
716
  };
689
- var scrapeBreakdown = ({ provider, bytes, requests, url }) => {
690
- if (provider === "brightdata") {
691
- return {
692
- provider,
693
- basis: "bytes",
694
- units: bytes || 0,
695
- rate: ratePerGB(url),
696
- rateUnit: "GB",
697
- cents: scrapeFee({ provider, bytes, url, cold: true })
698
- };
699
- }
700
- if (provider === "brightdata-unlocker") {
701
- return {
702
- provider,
703
- basis: "requests",
704
- units: requests || 0,
705
- // cpmCents is cents-per-request; ×10 expresses it as dollars per 1,000.
706
- rate: cpmCents(url) * 10,
707
- rateUnit: "1K requests",
708
- cents: scrapeFee({ provider, requests, url, cold: true })
709
- };
710
- }
711
- return { provider: provider || "local", basis: "local", units: null, rate: null, rateUnit: null, cents: LOCAL_FLAT_CENTS };
717
+ var resolvePlan = (subscription) => {
718
+ var _a, _b;
719
+ const custom = subscription == null ? void 0 : subscription.custom;
720
+ if (!custom) return plans[subscription == null ? void 0 : subscription.plan] ?? free;
721
+ return {
722
+ // Reusing all.features / all.limits is what keeps a custom plan the same
723
+ // SHAPE as a catalog one: the base feature grants every plan carries, the
724
+ // campaign limits that are always infinite, and members defaulting to
725
+ // infinite when a deal does not name it.
726
+ conversion: custom.conversion ?? free.conversion,
727
+ custom: true,
728
+ // A custom plan is a negotiated PAID deal, so it carries the paid-tier
729
+ // baseline whether or not the deal thought to name it. Today that is the
730
+ // sending domain: every catalog paid tier grants it, and a custom plan
731
+ // silently lacking it would be a support ticket, not a pricing decision.
732
+ features: all.features([organization.networking.key, ...((_a = custom.features) == null ? void 0 : _a.granted) || []]),
733
+ limits: all.limits(((_b = custom.limits) == null ? void 0 : _b.organization) || {}),
734
+ // A custom plan stores its overage BARE on `custom.overages`. Number() so
735
+ // a deal stored as a string still resolves to cents-per-action; an unnamed
736
+ // overage stays undefined (it bills nothing) rather than becoming NaN.
737
+ actionCents: custom.overages == null ? void 0 : Number(custom.overages),
738
+ title: custom.title || "Custom"
739
+ };
712
740
  };
713
- var scrape = {
714
- // Cents for a scrape given its provider/bytes/cold basis.
715
- fee: scrapeFee,
716
- // Itemized cold-fee breakdown (basis/units/rate/cents) for display + the scrape doc.
717
- breakdown: scrapeBreakdown,
718
- // Deferred bill(scrapeId) the scrape store's resolve() hands the generation
719
- // flow. Atomically claims the cold (first-time) fee via the `charged` flag so
720
- // a BrightData scrape's metered cost is recovered ONCE; later reuses pay the
721
- // flat rate. Best-effort — a billing hiccup never fails generation.
722
- bill: ({ controller, user, page: page2, url }) => async (scrapeId) => {
723
- var _a;
724
- if (!(user == null ? void 0 : user.id) || !scrapeId) return;
725
- try {
726
- const claim = await controller.update({
727
- collection: "scrape",
728
- data: { $set: { charged: true } },
729
- options: { bypassDocumentValidation: true },
730
- query: { id: scrapeId, charged: { $ne: true } }
731
- });
732
- const cents = scrapeFee({
733
- provider: (_a = page2 == null ? void 0 : page2.provider) == null ? void 0 : _a.name,
734
- bytes: page2 == null ? void 0 : page2.bytes,
735
- requests: page2 == null ? void 0 : page2.requests,
736
- url: (page2 == null ? void 0 : page2.url) || url,
737
- cold: Boolean(claim == null ? void 0 : claim.value)
738
- });
739
- if (cents > 0) await debit({ db: controller, user, amount: cents, type: "ai", category: "usage", source: "user" });
740
- } catch (error) {
741
- logger.error(error, { extra: { action: "generation.scrape.bill", scrapeId } });
742
- }
743
- }
741
+ var conversionRate = (subscription) => {
742
+ var _a;
743
+ return ((_a = resolvePlan(subscription)) == null ? void 0 : _a.conversion) ?? free.conversion;
744
744
  };
745
745
 
746
746
  // lib/pricing.js
@@ -812,6 +812,33 @@ var sending = {
812
812
  plan: emailPlan,
813
813
  plans: emailPlans,
814
814
  title: emailPlans[emailPlan].title
815
+ },
816
+ // What one outbound SMS SEGMENT costs us, against what channels.sms below
817
+ // sells it for. Here for the same reason the email figures are: this file is
818
+ // where a rate is found without knowing whose module owns it — and its
819
+ // absence is why "does an SMS action cover its cost" could not be answered
820
+ // from this repo at all.
821
+ //
822
+ // US toll-free outbound, read from Twilio's SMS pricing page 2026-09-03
823
+ // (https://www.twilio.com/en-us/sms/pricing/us): $0.0083 per segment, plus a
824
+ // carrier fee that varies by the DESTINATION carrier — $0.0035 AT&T, $0.0045
825
+ // T-Mobile / Verizon / US Cellular, $0.004 everywhere else. CENTS per
826
+ // segment, like every other rate in this file.
827
+ //
828
+ // WORST CASE, deliberately. A price floor computed from the average is a
829
+ // floor that half of real traffic sits underneath.
830
+ //
831
+ // A segment is 153 GSM-7 characters once a message spans more than one — or
832
+ // 67 if the body contains a single emoji or curly quote, which flips the
833
+ // whole message to UCS-2. Twilio reports the count as `num_segments` on the
834
+ // Message resource, which is what the send bills on.
835
+ sms: {
836
+ baseCents: 0.83,
837
+ // The dearest carrier fee, not the mean: see WORST CASE above.
838
+ carrierCents: 0.45,
839
+ // Stated, not summed — 0.83 + 0.45 is 1.2800000000000002 in binary
840
+ // floating point, and this number is compared against money.
841
+ segmentCostCents: 1.28
815
842
  }
816
843
  };
817
844
  var channels = {
@@ -827,6 +854,9 @@ var channels = {
827
854
  includedInAllowance: false
828
855
  }
829
856
  };
857
+ var MINIMUM_ACTION_CENTS = Math.round(
858
+ sending.sms.segmentCostCents / channels.sms.actionsPerSegment * MARKUP * 1e3
859
+ ) / 1e3;
830
860
  var numbers = {
831
861
  sms: {
832
862
  monthlyCents: 1500
@@ -835,6 +865,7 @@ var numbers = {
835
865
  export {
836
866
  LOCAL_FLAT_CENTS,
837
867
  MARKUP,
868
+ MINIMUM_ACTION_CENTS,
838
869
  PREMIUM_CPM_CENTS,
839
870
  PREMIUM_RATE_PER_GB,
840
871
  REUSE_FLAT_CENTS,