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