@drawbridge/drawbridge-utils 0.0.102 → 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/plans.cjs CHANGED
@@ -32,7 +32,8 @@ __export(plans_exports, {
32
32
  conversionRate: () => conversionRate,
33
33
  free: () => free,
34
34
  plans: () => plans,
35
- resolvePlan: () => resolvePlan
35
+ resolvePlan: () => resolvePlan,
36
+ unitAmountDecimal: () => unitAmountDecimal
36
37
  });
37
38
  module.exports = __toCommonJS(plans_exports);
38
39
 
@@ -109,6 +110,11 @@ var connection = {
109
110
  error: "Plan does not include Mailchimp connection",
110
111
  feature: "Mailchimp connection"
111
112
  },
113
+ sender: {
114
+ key: "organization:connection:sender",
115
+ error: "Plan does not include a verified sending domain",
116
+ feature: "Sending domain connection"
117
+ },
112
118
  sendgrid: {
113
119
  key: "organization:connection:sendgrid",
114
120
  error: "Plan does not include SendGrid connection",
@@ -262,6 +268,15 @@ var featuresFor = (array = []) => Object.values({
262
268
  },
263
269
  { denied: {}, granted: {} }
264
270
  );
271
+ var overage = (actionCents) => ({
272
+ actionCents,
273
+ overages: { actions: String(actionCents) }
274
+ });
275
+ var unitAmountDecimal = (cents) => {
276
+ const value = Number(cents);
277
+ if (cents == null || !Number.isFinite(value)) throw new Error(`Invalid overage rate: ${cents}`);
278
+ return String(value);
279
+ };
265
280
  var all = {
266
281
  features: (array = []) => featuresFor([
267
282
  connection.mailchimp.key,
@@ -322,7 +337,11 @@ var free = {
322
337
  };
323
338
  var plans = {
324
339
  DB00002: {
325
- features: all.features([organization.members.key]),
340
+ // A verified sending domain is a PAID capability: free plans cannot send
341
+ // lead-facing email at all (the send path gates on an active
342
+ // subscription), so granting it there would offer a domain that can
343
+ // never send from.
344
+ features: all.features([connection.sender.key, organization.members.key]),
326
345
  limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
327
346
  marketing: {
328
347
  description: "Tools to fine-tune campaigns and improve lead quality.",
@@ -337,12 +356,13 @@ var plans = {
337
356
  ["Storage", "10GB"]
338
357
  ]
339
358
  },
340
- overages: { actions: "2.5" },
359
+ ...overage(2.5),
341
360
  title: "Starter",
342
361
  conversion: 2
343
362
  },
344
363
  DB00003: {
345
364
  features: all.features([
365
+ connection.sender.key,
346
366
  organization.advertisements.key,
347
367
  organization.analytics.key,
348
368
  organization.members.key,
@@ -367,12 +387,13 @@ var plans = {
367
387
  ["Storage", "20GB"]
368
388
  ]
369
389
  },
370
- overages: { actions: "2" },
390
+ ...overage(2),
371
391
  title: "Pro",
372
392
  conversion: 1.5
373
393
  },
374
394
  DB00004: {
375
395
  features: all.features([
396
+ connection.sender.key,
376
397
  organization.advertisements.key,
377
398
  organization.analytics.key,
378
399
  organization.members.key,
@@ -397,12 +418,13 @@ var plans = {
397
418
  ["Storage", "50GB"]
398
419
  ]
399
420
  },
400
- overages: { actions: "1.85" },
421
+ ...overage(1.85),
401
422
  title: "Premium",
402
423
  conversion: 1
403
424
  },
404
425
  DB00005: {
405
426
  features: all.features([
427
+ connection.sender.key,
406
428
  organization.advertisements.key,
407
429
  organization.analytics.key,
408
430
  organization.members.key,
@@ -427,7 +449,7 @@ var plans = {
427
449
  ["Storage", "100GB"]
428
450
  ]
429
451
  },
430
- overages: { actions: "1.5" },
452
+ ...overage(1.5),
431
453
  title: "Elite",
432
454
  conversion: 0.5
433
455
  }
@@ -443,8 +465,17 @@ var resolvePlan = (subscription) => {
443
465
  // infinite when a deal does not name it.
444
466
  conversion: custom.conversion ?? free.conversion,
445
467
  custom: true,
446
- features: all.features(((_a = custom.features) == null ? void 0 : _a.granted) || []),
468
+ // A custom plan is a negotiated PAID deal, so it carries the paid-tier
469
+ // baseline whether or not the deal thought to name it. Today that is the
470
+ // sending domain: every catalog paid tier grants it, and a custom plan
471
+ // silently lacking it would be a support ticket, not a pricing decision.
472
+ features: all.features([connection.sender.key, ...((_a = custom.features) == null ? void 0 : _a.granted) || []]),
447
473
  limits: all.limits(((_b = custom.limits) == null ? void 0 : _b.organization) || {}),
474
+ // A custom plan stores its overage BARE on `custom.overages` — a different
475
+ // shape from the catalog's nested one. Number() so a deal stored as a string
476
+ // still resolves to cents-per-action; an unnamed overage stays undefined
477
+ // (it bills nothing) rather than becoming NaN.
478
+ actionCents: custom.overages == null ? void 0 : Number(custom.overages),
448
479
  overages: { actions: custom.overages },
449
480
  title: custom.title || "Custom"
450
481
  };
@@ -458,5 +489,6 @@ var conversionRate = (subscription) => {
458
489
  conversionRate,
459
490
  free,
460
491
  plans,
461
- resolvePlan
492
+ resolvePlan,
493
+ unitAmountDecimal
462
494
  });
package/dist/plans.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { organization, page, connection, fields, field } from './features.cjs';
1
+ import { connection, organization, page, fields, field } from './features.cjs';
2
2
  import { infinite, gigabyte } from './index.cjs';
3
3
  import 'currency-codes';
4
4
  import 'nanoid';
@@ -31,6 +31,40 @@ const featuresFor = ( array = [] ) => Object.values({
31
31
  { denied : {}, granted : {} }
32
32
  );
33
33
 
34
+ // Overage pricing, unit-explicit.
35
+ //
36
+ // `actionCents` is CENTS PER ACTION, as a number: 2.5 = $0.025. Before
37
+ // 2026-05-31 the same rate was encoded as dollars-per-1,000-actions, Stripe read
38
+ // it as per-action, and every paid tier billed 10x. `overages.actions` — the
39
+ // string this used to be the only name for — is kept as a byte-identical alias
40
+ // so consumers can be pin-bumped in any order; drop it once they all read
41
+ // `actionCents`.
42
+ //
43
+ // Sub-cent precision is deliberate: Premium's 1.85¢ does not fit integer cents
44
+ // (2¢ is +8%, 1¢ is −46%).
45
+ const overage = ( actionCents ) => ({
46
+ actionCents,
47
+ overages : { actions : String( actionCents ) }
48
+ });
49
+
50
+ // Cents → Stripe's `unit_amount_decimal`.
51
+ //
52
+ // The Stripe boundary MUST use `unit_amount_decimal`, not `unit_amount`:
53
+ // standalone invoice items reject a top-level `unit_amount` with "Received
54
+ // unknown parameter", which is what left invoice drafts stuck on 2026-08-06.
55
+ // It is a decimal string of CENTS, so 2.5 → '2.5' — no scaling, no rounding.
56
+ const unitAmountDecimal = ( cents ) => {
57
+
58
+ const value = Number( cents );
59
+
60
+ // `cents == null` is checked separately because Number( null ) is 0 — an
61
+ // unnamed rate must fail loudly, not silently bill nothing.
62
+ if( cents == null || ! Number.isFinite( value ) ) throw new Error( `Invalid overage rate: ${ cents }` );
63
+
64
+ return String( value );
65
+
66
+ };
67
+
34
68
  const all = {
35
69
  features : ( array = [] ) => featuresFor([
36
70
  connection.mailchimp.key,
@@ -93,7 +127,11 @@ const free = {
93
127
 
94
128
  const plans = {
95
129
  DB00002 : {
96
- features : all.features([ organization.members.key ]),
130
+ // A verified sending domain is a PAID capability: free plans cannot send
131
+ // lead-facing email at all (the send path gates on an active
132
+ // subscription), so granting it there would offer a domain that can
133
+ // never send from.
134
+ features : all.features([ connection.sender.key, organization.members.key ]),
97
135
  limits : all.limits({ actions : 5000, members : 3, storage : gigabyte * 10 }),
98
136
  marketing : {
99
137
  description : 'Tools to fine-tune campaigns and improve lead quality.',
@@ -108,12 +146,13 @@ const plans = {
108
146
  [ 'Storage', '10GB' ]
109
147
  ]
110
148
  },
111
- overages : { actions : '2.5' },
149
+ ...overage( 2.5 ),
112
150
  title : 'Starter',
113
151
  conversion : 2
114
152
  },
115
153
  DB00003 : {
116
154
  features : all.features([
155
+ connection.sender.key,
117
156
  organization.advertisements.key,
118
157
  organization.analytics.key,
119
158
  organization.members.key,
@@ -138,12 +177,13 @@ const plans = {
138
177
  [ 'Storage', '20GB' ]
139
178
  ]
140
179
  },
141
- overages : { actions : '2' },
180
+ ...overage( 2 ),
142
181
  title : 'Pro',
143
182
  conversion : 1.5
144
183
  },
145
184
  DB00004 : {
146
185
  features : all.features([
186
+ connection.sender.key,
147
187
  organization.advertisements.key,
148
188
  organization.analytics.key,
149
189
  organization.members.key,
@@ -168,12 +208,13 @@ const plans = {
168
208
  [ 'Storage', '50GB' ]
169
209
  ]
170
210
  },
171
- overages : { actions : '1.85' },
211
+ ...overage( 1.85 ),
172
212
  title : 'Premium',
173
213
  conversion : 1
174
214
  },
175
215
  DB00005 : {
176
216
  features : all.features([
217
+ connection.sender.key,
177
218
  organization.advertisements.key,
178
219
  organization.analytics.key,
179
220
  organization.members.key,
@@ -198,7 +239,7 @@ const plans = {
198
239
  [ 'Storage', '100GB' ]
199
240
  ]
200
241
  },
201
- overages : { actions : '1.5' },
242
+ ...overage( 1.5 ),
202
243
  title : 'Elite',
203
244
  conversion : 0.5
204
245
  },
@@ -230,8 +271,17 @@ const resolvePlan = ( subscription ) => {
230
271
  // infinite when a deal does not name it.
231
272
  conversion : custom.conversion ?? free.conversion,
232
273
  custom : true,
233
- features : all.features( custom.features?.granted || [] ),
274
+ // A custom plan is a negotiated PAID deal, so it carries the paid-tier
275
+ // baseline whether or not the deal thought to name it. Today that is the
276
+ // sending domain: every catalog paid tier grants it, and a custom plan
277
+ // silently lacking it would be a support ticket, not a pricing decision.
278
+ features : all.features([ connection.sender.key, ...( custom.features?.granted || [] ) ]),
234
279
  limits : all.limits( custom.limits?.organization || {} ),
280
+ // A custom plan stores its overage BARE on `custom.overages` — a different
281
+ // shape from the catalog's nested one. Number() so a deal stored as a string
282
+ // still resolves to cents-per-action; an unnamed overage stays undefined
283
+ // (it bills nothing) rather than becoming NaN.
284
+ actionCents : custom.overages == null ? undefined : Number( custom.overages ),
235
285
  overages : { actions : custom.overages },
236
286
  title : custom.title || 'Custom'
237
287
  };
@@ -250,4 +300,4 @@ const conversionRate = ( subscription ) =>
250
300
  resolvePlan( subscription )?.conversion
251
301
  ?? free.conversion;
252
302
 
253
- export { conversionRate, free, plans, resolvePlan };
303
+ export { conversionRate, free, plans, resolvePlan, unitAmountDecimal };
package/dist/plans.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { organization, page, connection, fields, field } from './features.js';
1
+ import { connection, organization, page, fields, field } from './features.js';
2
2
  import { infinite, gigabyte } from './index.js';
3
3
  import 'currency-codes';
4
4
  import 'nanoid';
@@ -31,6 +31,40 @@ const featuresFor = ( array = [] ) => Object.values({
31
31
  { denied : {}, granted : {} }
32
32
  );
33
33
 
34
+ // Overage pricing, unit-explicit.
35
+ //
36
+ // `actionCents` is CENTS PER ACTION, as a number: 2.5 = $0.025. Before
37
+ // 2026-05-31 the same rate was encoded as dollars-per-1,000-actions, Stripe read
38
+ // it as per-action, and every paid tier billed 10x. `overages.actions` — the
39
+ // string this used to be the only name for — is kept as a byte-identical alias
40
+ // so consumers can be pin-bumped in any order; drop it once they all read
41
+ // `actionCents`.
42
+ //
43
+ // Sub-cent precision is deliberate: Premium's 1.85¢ does not fit integer cents
44
+ // (2¢ is +8%, 1¢ is −46%).
45
+ const overage = ( actionCents ) => ({
46
+ actionCents,
47
+ overages : { actions : String( actionCents ) }
48
+ });
49
+
50
+ // Cents → Stripe's `unit_amount_decimal`.
51
+ //
52
+ // The Stripe boundary MUST use `unit_amount_decimal`, not `unit_amount`:
53
+ // standalone invoice items reject a top-level `unit_amount` with "Received
54
+ // unknown parameter", which is what left invoice drafts stuck on 2026-08-06.
55
+ // It is a decimal string of CENTS, so 2.5 → '2.5' — no scaling, no rounding.
56
+ const unitAmountDecimal = ( cents ) => {
57
+
58
+ const value = Number( cents );
59
+
60
+ // `cents == null` is checked separately because Number( null ) is 0 — an
61
+ // unnamed rate must fail loudly, not silently bill nothing.
62
+ if( cents == null || ! Number.isFinite( value ) ) throw new Error( `Invalid overage rate: ${ cents }` );
63
+
64
+ return String( value );
65
+
66
+ };
67
+
34
68
  const all = {
35
69
  features : ( array = [] ) => featuresFor([
36
70
  connection.mailchimp.key,
@@ -93,7 +127,11 @@ const free = {
93
127
 
94
128
  const plans = {
95
129
  DB00002 : {
96
- features : all.features([ organization.members.key ]),
130
+ // A verified sending domain is a PAID capability: free plans cannot send
131
+ // lead-facing email at all (the send path gates on an active
132
+ // subscription), so granting it there would offer a domain that can
133
+ // never send from.
134
+ features : all.features([ connection.sender.key, organization.members.key ]),
97
135
  limits : all.limits({ actions : 5000, members : 3, storage : gigabyte * 10 }),
98
136
  marketing : {
99
137
  description : 'Tools to fine-tune campaigns and improve lead quality.',
@@ -108,12 +146,13 @@ const plans = {
108
146
  [ 'Storage', '10GB' ]
109
147
  ]
110
148
  },
111
- overages : { actions : '2.5' },
149
+ ...overage( 2.5 ),
112
150
  title : 'Starter',
113
151
  conversion : 2
114
152
  },
115
153
  DB00003 : {
116
154
  features : all.features([
155
+ connection.sender.key,
117
156
  organization.advertisements.key,
118
157
  organization.analytics.key,
119
158
  organization.members.key,
@@ -138,12 +177,13 @@ const plans = {
138
177
  [ 'Storage', '20GB' ]
139
178
  ]
140
179
  },
141
- overages : { actions : '2' },
180
+ ...overage( 2 ),
142
181
  title : 'Pro',
143
182
  conversion : 1.5
144
183
  },
145
184
  DB00004 : {
146
185
  features : all.features([
186
+ connection.sender.key,
147
187
  organization.advertisements.key,
148
188
  organization.analytics.key,
149
189
  organization.members.key,
@@ -168,12 +208,13 @@ const plans = {
168
208
  [ 'Storage', '50GB' ]
169
209
  ]
170
210
  },
171
- overages : { actions : '1.85' },
211
+ ...overage( 1.85 ),
172
212
  title : 'Premium',
173
213
  conversion : 1
174
214
  },
175
215
  DB00005 : {
176
216
  features : all.features([
217
+ connection.sender.key,
177
218
  organization.advertisements.key,
178
219
  organization.analytics.key,
179
220
  organization.members.key,
@@ -198,7 +239,7 @@ const plans = {
198
239
  [ 'Storage', '100GB' ]
199
240
  ]
200
241
  },
201
- overages : { actions : '1.5' },
242
+ ...overage( 1.5 ),
202
243
  title : 'Elite',
203
244
  conversion : 0.5
204
245
  },
@@ -230,8 +271,17 @@ const resolvePlan = ( subscription ) => {
230
271
  // infinite when a deal does not name it.
231
272
  conversion : custom.conversion ?? free.conversion,
232
273
  custom : true,
233
- features : all.features( custom.features?.granted || [] ),
274
+ // A custom plan is a negotiated PAID deal, so it carries the paid-tier
275
+ // baseline whether or not the deal thought to name it. Today that is the
276
+ // sending domain: every catalog paid tier grants it, and a custom plan
277
+ // silently lacking it would be a support ticket, not a pricing decision.
278
+ features : all.features([ connection.sender.key, ...( custom.features?.granted || [] ) ]),
234
279
  limits : all.limits( custom.limits?.organization || {} ),
280
+ // A custom plan stores its overage BARE on `custom.overages` — a different
281
+ // shape from the catalog's nested one. Number() so a deal stored as a string
282
+ // still resolves to cents-per-action; an unnamed overage stays undefined
283
+ // (it bills nothing) rather than becoming NaN.
284
+ actionCents : custom.overages == null ? undefined : Number( custom.overages ),
235
285
  overages : { actions : custom.overages },
236
286
  title : custom.title || 'Custom'
237
287
  };
@@ -250,4 +300,4 @@ const conversionRate = ( subscription ) =>
250
300
  resolvePlan( subscription )?.conversion
251
301
  ?? free.conversion;
252
302
 
253
- export { conversionRate, free, plans, resolvePlan };
303
+ export { conversionRate, free, plans, resolvePlan, unitAmountDecimal };
package/dist/plans.js CHANGED
@@ -71,6 +71,11 @@ var connection = {
71
71
  error: "Plan does not include Mailchimp connection",
72
72
  feature: "Mailchimp connection"
73
73
  },
74
+ sender: {
75
+ key: "organization:connection:sender",
76
+ error: "Plan does not include a verified sending domain",
77
+ feature: "Sending domain connection"
78
+ },
74
79
  sendgrid: {
75
80
  key: "organization:connection:sendgrid",
76
81
  error: "Plan does not include SendGrid connection",
@@ -224,6 +229,15 @@ var featuresFor = (array = []) => Object.values({
224
229
  },
225
230
  { denied: {}, granted: {} }
226
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
+ };
227
241
  var all = {
228
242
  features: (array = []) => featuresFor([
229
243
  connection.mailchimp.key,
@@ -284,7 +298,11 @@ var free = {
284
298
  };
285
299
  var plans = {
286
300
  DB00002: {
287
- features: all.features([organization.members.key]),
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]),
288
306
  limits: all.limits({ actions: 5e3, members: 3, storage: gigabyte * 10 }),
289
307
  marketing: {
290
308
  description: "Tools to fine-tune campaigns and improve lead quality.",
@@ -299,12 +317,13 @@ var plans = {
299
317
  ["Storage", "10GB"]
300
318
  ]
301
319
  },
302
- overages: { actions: "2.5" },
320
+ ...overage(2.5),
303
321
  title: "Starter",
304
322
  conversion: 2
305
323
  },
306
324
  DB00003: {
307
325
  features: all.features([
326
+ connection.sender.key,
308
327
  organization.advertisements.key,
309
328
  organization.analytics.key,
310
329
  organization.members.key,
@@ -329,12 +348,13 @@ var plans = {
329
348
  ["Storage", "20GB"]
330
349
  ]
331
350
  },
332
- overages: { actions: "2" },
351
+ ...overage(2),
333
352
  title: "Pro",
334
353
  conversion: 1.5
335
354
  },
336
355
  DB00004: {
337
356
  features: all.features([
357
+ connection.sender.key,
338
358
  organization.advertisements.key,
339
359
  organization.analytics.key,
340
360
  organization.members.key,
@@ -359,12 +379,13 @@ var plans = {
359
379
  ["Storage", "50GB"]
360
380
  ]
361
381
  },
362
- overages: { actions: "1.85" },
382
+ ...overage(1.85),
363
383
  title: "Premium",
364
384
  conversion: 1
365
385
  },
366
386
  DB00005: {
367
387
  features: all.features([
388
+ connection.sender.key,
368
389
  organization.advertisements.key,
369
390
  organization.analytics.key,
370
391
  organization.members.key,
@@ -389,7 +410,7 @@ var plans = {
389
410
  ["Storage", "100GB"]
390
411
  ]
391
412
  },
392
- overages: { actions: "1.5" },
413
+ ...overage(1.5),
393
414
  title: "Elite",
394
415
  conversion: 0.5
395
416
  }
@@ -405,8 +426,17 @@ var resolvePlan = (subscription) => {
405
426
  // infinite when a deal does not name it.
406
427
  conversion: custom.conversion ?? free.conversion,
407
428
  custom: true,
408
- features: all.features(((_a = custom.features) == null ? void 0 : _a.granted) || []),
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) || []]),
409
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),
410
440
  overages: { actions: custom.overages },
411
441
  title: custom.title || "Custom"
412
442
  };
@@ -419,5 +449,6 @@ export {
419
449
  conversionRate,
420
450
  free,
421
451
  plans,
422
- resolvePlan
452
+ resolvePlan,
453
+ unitAmountDecimal
423
454
  };