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