@oxygen-agent/cli 1.1010.650 → 1.1010.905
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/README.md +1 -1
- package/dist/auto-update.d.ts +129 -0
- package/dist/auto-update.js +392 -0
- package/dist/command-manifest.js +15 -1
- package/dist/credentials.d.ts +2 -0
- package/dist/credentials.js +6 -3
- package/dist/functions-commands.js +1 -1
- package/dist/http-client.js +28 -4
- package/dist/inbox-needs-reply-notice.d.ts +12 -0
- package/dist/inbox-needs-reply-notice.js +51 -0
- package/dist/index.js +756 -177
- package/dist/run-wait.d.ts +3 -1
- package/dist/run-wait.js +19 -5
- package/dist/skills.js +48 -22
- package/dist/streamed-file-import.d.ts +58 -0
- package/dist/streamed-file-import.js +115 -0
- package/dist/update.d.ts +29 -0
- package/dist/update.js +62 -16
- package/dist/workflow-plan-limit-notices.d.ts +8 -0
- package/dist/workflow-plan-limit-notices.js +28 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +3 -3
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +50 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +94 -2
- package/node_modules/@oxygen/shared/dist/billing.d.ts +247 -37
- package/node_modules/@oxygen/shared/dist/billing.js +418 -45
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +66 -6
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +6 -6
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +6 -6
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +90 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +167 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +361 -0
- package/node_modules/@oxygen/shared/dist/credit-gate.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/credit-gate.js +65 -0
- package/node_modules/@oxygen/shared/dist/email-deliverability-policy.d.ts +51 -0
- package/node_modules/@oxygen/shared/dist/email-deliverability-policy.js +101 -0
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +27 -0
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
- package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -1
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +10 -1
- package/node_modules/@oxygen/shared/dist/feature-gates.js +12 -1
- package/node_modules/@oxygen/shared/dist/file-import.d.ts +13 -1
- package/node_modules/@oxygen/shared/dist/file-import.js +33 -6
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +73 -3
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +246 -24
- package/node_modules/@oxygen/shared/dist/import-limits.d.ts +25 -1
- package/node_modules/@oxygen/shared/dist/import-limits.js +35 -2
- package/node_modules/@oxygen/shared/dist/index.d.ts +4 -23
- package/node_modules/@oxygen/shared/dist/index.js +4 -43
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +114 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +150 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +17 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +55 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +19 -2
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +234 -0
- package/node_modules/@oxygen/shared/dist/plan-band.js +312 -0
- package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +77 -7
- package/node_modules/@oxygen/shared/dist/plan-capabilities.js +87 -7
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +219 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +330 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +335 -126
- package/node_modules/@oxygen/shared/dist/plan-limits.js +277 -86
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +158 -49
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +139 -41
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +42 -23
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +56 -37
- package/node_modules/@oxygen/shared/dist/process-resource.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/process-resource.js +25 -0
- package/node_modules/@oxygen/shared/dist/provider-http-error.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/provider-http-error.js +27 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +257 -0
- package/node_modules/@oxygen/shared/dist/repricing.js +721 -0
- package/node_modules/@oxygen/shared/dist/semver.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/semver.js +41 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +30 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.js +43 -0
- package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +18 -15
- package/node_modules/@oxygen/shared/dist/sending-seats.js +22 -17
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +57 -8
- package/node_modules/@oxygen/shared/dist/spend-safety.js +64 -11
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +33 -1
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +71 -1
- package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +68 -10
- package/node_modules/@oxygen/shared/dist/table-capacity.js +85 -4
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +13 -5
- package/node_modules/@oxygen/shared/dist/telemetry-resource.d.ts +40 -0
- package/node_modules/@oxygen/shared/dist/telemetry-resource.js +35 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +41 -2
- package/node_modules/@oxygen/shared/dist/trace-context.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/trace-context.js +88 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/ugc.js +29 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -3
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +14 -27
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +5 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +3 -3
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +15 -1
- package/node_modules/@oxygen/workflows/dist/graph/types.js +15 -1
- package/node_modules/@oxygen/workflows/dist/index.d.ts +45 -0
- package/node_modules/@oxygen/workflows/dist/index.js +152 -2
- package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +10 -1
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +33 -29
- package/package.json +1 -1
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +0 -64
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +0 -90
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, PRICING_PLAN_SNAPSHOT, MANAGED_INBOX_CREDITS_PER_MONTH as SNAPSHOT_MANAGED_MAILBOX_CREDITS_PER_MONTH, SENDING_MAILBOX_CREDITS_PER_MONTH as SNAPSHOT_SENDING_MAILBOX_CREDITS_PER_MONTH, } from "./pricing-snapshot.generated.js";
|
|
2
|
+
import { currentSubscriptionGrantSlice, dueSubscriptionGrantSlices, } from "./billing-anchors.js";
|
|
2
3
|
import { freeTierEntitlementEnabled } from "./plan-capabilities.js";
|
|
4
|
+
import { AUTOMATION_STEP_CREDITS_REPRICING, AUTOMATION_STEPS_COUNT_TOWARD_RUN_CAP_REPRICING, isRepricingInForce, BYOK_PLATFORM_FEE_REPRICING, OXYGEN_49_BYOK_REPRICING, repricedValue, TOPUP_RATE_REPRICING, } from "./repricing.js";
|
|
3
5
|
import { asRecordOrNull } from "./type-guards.js";
|
|
4
6
|
export const WEEKLY_USAGE_WINDOW_DAYS = 7;
|
|
5
7
|
export const BILLING_CURRENCIES = ["usd"];
|
|
@@ -34,10 +36,10 @@ export const OXYGEN_PLAN_KEYS = [
|
|
|
34
36
|
// person's booking page into every public pricing card.
|
|
35
37
|
export const CONTACT_SALES_URL = "mailto:priority@oxygen-agent.com";
|
|
36
38
|
// One Oxygen credit is worth $0.01 of managed work: $1 = 100 credits.
|
|
37
|
-
// Subscription plans convert at face value — every subscription dollar
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// credits
|
|
39
|
+
// Subscription plans convert at least at face value — every subscription dollar
|
|
40
|
+
// grants 100 credits, and the $499, $999 and $1,999 plans add a volume bonus on
|
|
41
|
+
// top (see planVolumeBonusCredits) — while on-demand top-ups price the same
|
|
42
|
+
// credit higher, so subscription credits are always the cheaper way to buy.
|
|
41
43
|
//
|
|
42
44
|
// Re-denominated from $1 = 1,000 credits on 2026-09-19. That was a PURE unit
|
|
43
45
|
// change: every credit-denominated price divided by 10 in the same release, so
|
|
@@ -48,11 +50,23 @@ export const CONTACT_SALES_URL = "mailto:priority@oxygen-agent.com";
|
|
|
48
50
|
// disagreed every markup-derived price would be silently wrong by the ratio.
|
|
49
51
|
// Change them in packages/control-db/src/pricing/seed.ts.
|
|
50
52
|
export const CREDITS_PER_USD = SNAPSHOT_CREDITS_PER_USD;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
/**
|
|
54
|
+
* US cents per 100 on-demand top-up credits right now. The rate rises from 125
|
|
55
|
+
* ($1.25) to the seed's 130 ($1.30) at the 2026-09 repricing's effective instant
|
|
56
|
+
* (decision 1.5, slice S10), so it is read per call through the switch and never
|
|
57
|
+
* from the snapshot: plan credits are then at least 23% cheaper (1 - 1.00/1.30),
|
|
58
|
+
* where they were 20% cheaper at $1.25. A page that shows it resolves it on the
|
|
59
|
+
* server and passes the number down, because a client bundle cannot read the
|
|
60
|
+
* switch.
|
|
61
|
+
*/
|
|
62
|
+
export function creditTopupUsdCentsPer100(options = {}) {
|
|
63
|
+
return repricedValue(TOPUP_RATE_REPRICING, options);
|
|
64
|
+
}
|
|
65
|
+
// On-demand purchases run from 800 to 100,000 credits in any 100-credit
|
|
66
|
+
// increment ($10.00 to $1,250 at $1.25 per 100; $10.40 to $1,300 at $1.30). The
|
|
67
|
+
// ceiling is a single-checkout guard rail, not a spend cap: a workspace that
|
|
68
|
+
// needs more tops up again (purchased credits never expire). The range and step
|
|
69
|
+
// are unchanged by the 2026-09 repricing (proposal P-7).
|
|
56
70
|
export const CREDIT_TOPUP_MIN_CREDITS = 800;
|
|
57
71
|
export const CREDIT_TOPUP_MAX_CREDITS = 100_000;
|
|
58
72
|
export const CREDIT_TOPUP_STEP_CREDITS = 100;
|
|
@@ -65,8 +79,8 @@ export function isValidCreditTopupCredits(credits) {
|
|
|
65
79
|
}
|
|
66
80
|
/**
|
|
67
81
|
* A shortfall expressed as an amount `oxygen billing topup` will actually SELL:
|
|
68
|
-
* rounded up onto
|
|
69
|
-
* [
|
|
82
|
+
* rounded up onto CREDIT_TOPUP_STEP_CREDITS (100) and clamped into the purchasable
|
|
83
|
+
* [CREDIT_TOPUP_MIN_CREDITS .. CREDIT_TOPUP_MAX_CREDITS] band (800 .. 100,000). A next_action (or an alert email) naming a number the
|
|
70
84
|
* top-up route refuses with `invalid_topup_amount` is not a next action, it is a
|
|
71
85
|
* second dead end during the incident it exists to end.
|
|
72
86
|
*
|
|
@@ -83,16 +97,87 @@ export function creditTopupAmountForShortfall(credits) {
|
|
|
83
97
|
const stepped = Math.ceil(credits / CREDIT_TOPUP_STEP_CREDITS) * CREDIT_TOPUP_STEP_CREDITS;
|
|
84
98
|
return Math.min(CREDIT_TOPUP_MAX_CREDITS, Math.max(CREDIT_TOPUP_MIN_CREDITS, stepped));
|
|
85
99
|
}
|
|
86
|
-
|
|
100
|
+
/** Price of a custom top-up at the rate in force now (`creditTopupUsdCentsPer100`). */
|
|
101
|
+
export function creditTopupUsdCents(credits, options = {}) {
|
|
102
|
+
return creditTopupUsdCentsAtRate(credits, creditTopupUsdCentsPer100(options));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Price of a custom top-up at an explicit rate. A checkout session records the
|
|
106
|
+
* rate it was quoted at (`CREDIT_TOPUP_RATE_METADATA_KEY`), and fulfillment
|
|
107
|
+
* re-prices it at THAT rate, never the rate in force when Stripe confirms the
|
|
108
|
+
* payment: a session opened before a rate change and paid after it must still
|
|
109
|
+
* be credited.
|
|
110
|
+
*/
|
|
111
|
+
export function creditTopupUsdCentsAtRate(credits, usdCentsPer100) {
|
|
87
112
|
if (!isValidCreditTopupCredits(credits))
|
|
88
113
|
return null;
|
|
89
|
-
|
|
114
|
+
if (!Number.isInteger(usdCentsPer100) || usdCentsPer100 <= 0)
|
|
115
|
+
return null;
|
|
116
|
+
return (credits / 100) * usdCentsPer100;
|
|
117
|
+
}
|
|
118
|
+
/** Checkout-session metadata key carrying the rate a top-up was quoted at. */
|
|
119
|
+
export const CREDIT_TOPUP_RATE_METADATA_KEY = "usd_cents_per_100";
|
|
120
|
+
/**
|
|
121
|
+
* Every top-up rate (US cents per 100 credits) a checkout has been or can be
|
|
122
|
+
* minted at. Append when the rate changes; never remove one, because an unpaid
|
|
123
|
+
* session quoted at it can still be paid. The first entry is also the rate of
|
|
124
|
+
* sessions created before the rate was written into their metadata.
|
|
125
|
+
*
|
|
126
|
+
* 130 is the 2026-09 rate (decision 1.5). It is listed before the switch's
|
|
127
|
+
* instant as well, so a session minted after the instant is still credited if
|
|
128
|
+
* an operator rolls the switch back before the customer pays. Accepting a rate
|
|
129
|
+
* higher than the one in force can never credit a purchase cheaply.
|
|
130
|
+
*/
|
|
131
|
+
export const CREDIT_TOPUP_SOLD_USD_CENTS_PER_100 = [125, 130];
|
|
132
|
+
export const CREDIT_TOPUP_LEGACY_USD_CENTS_PER_100 = CREDIT_TOPUP_SOLD_USD_CENTS_PER_100[0];
|
|
133
|
+
/**
|
|
134
|
+
* Whether fulfillment may price a session at `usdCentsPer100`: a rate OXYGEN has
|
|
135
|
+
* sold at, or the rate in force now. Anything else is metadata no OXYGEN server
|
|
136
|
+
* wrote, and it is refused rather than credited at a cheaper rate.
|
|
137
|
+
*/
|
|
138
|
+
export function isAcceptedCreditTopupRate(usdCentsPer100, currentUsdCentsPer100 = creditTopupUsdCentsPer100()) {
|
|
139
|
+
if (!Number.isInteger(usdCentsPer100) || usdCentsPer100 <= 0)
|
|
140
|
+
return false;
|
|
141
|
+
return usdCentsPer100 === currentUsdCentsPer100
|
|
142
|
+
|| CREDIT_TOPUP_SOLD_USD_CENTS_PER_100.includes(usdCentsPer100);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Credits one workflow step costs right now. Every step a live run executes
|
|
146
|
+
* (a manifest step, a billable graph node, a recipe checkpoint, the durable
|
|
147
|
+
* recipe runtime) draws this from the one credit pool; rows written inside a run
|
|
148
|
+
* are free (repricing 2026-09, decisions 6.1 and 6.2). Seed key:
|
|
149
|
+
* charge.automation.action.
|
|
150
|
+
*
|
|
151
|
+
* The step price rises 0.001 → 0.05 at the repricing's effective instant (S07),
|
|
152
|
+
* so it is read per call through the switch and never from the snapshot. A page
|
|
153
|
+
* that shows it resolves it on the server and passes the number down.
|
|
154
|
+
*/
|
|
155
|
+
export function automationStepCredits(options = {}) {
|
|
156
|
+
return repricedValue(AUTOMATION_STEP_CREDITS_REPRICING, options);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Credits one provider call made with the customer's own API key (BYOK) costs
|
|
160
|
+
* right now: the platform fee of decision 6.3. The vendor bills the call to the
|
|
161
|
+
* customer's own account; this is OXYGEN's fee for running it. Seed key:
|
|
162
|
+
* charge.byok.platform_fee.
|
|
163
|
+
*
|
|
164
|
+
* 0 until the repricing's effective instant (S07), then 0.05, so it is read per
|
|
165
|
+
* call through the switch and never from the snapshot. Every BYOK provider call
|
|
166
|
+
* is admitted and captured at this price by `chargeByokCall`
|
|
167
|
+
* (`@oxygen/integrations`).
|
|
168
|
+
*/
|
|
169
|
+
export function byokPlatformFeeCredits(options = {}) {
|
|
170
|
+
return repricedValue(BYOK_PLATFORM_FEE_REPRICING, options);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Whether the step credits a live run executes count toward its approved
|
|
174
|
+
* `max_credits` and a trigger's per-delivery ceiling (proposal P-45). Dark until
|
|
175
|
+
* the step price rises with it: counting 0.001 steps would only tighten ceilings
|
|
176
|
+
* customers already approved.
|
|
177
|
+
*/
|
|
178
|
+
export function automationStepsCountTowardRunCap(options = {}) {
|
|
179
|
+
return repricedValue(AUTOMATION_STEPS_COUNT_TOWARD_RUN_CAP_REPRICING, options);
|
|
90
180
|
}
|
|
91
|
-
// Workflow/automation actions (steps, tool calls, row writes, retries) draw
|
|
92
|
-
// from the same credit pool at a flat capacity-guard rate — the old separate
|
|
93
|
-
// monthly action allowance + USD overage meter economy is retired (2026-07-14).
|
|
94
|
-
// Seed key: charge.automation.action.
|
|
95
|
-
export const AUTOMATION_ACTION_CREDITS = SNAPSHOT_AUTOMATION_ACTION_CREDITS;
|
|
96
181
|
// ---------------------------------------------------------------------------
|
|
97
182
|
// FIXED (committed) credit commitments — recurring per-resource monthly charges
|
|
98
183
|
// ---------------------------------------------------------------------------
|
|
@@ -122,26 +207,47 @@ export const CREDIT_COMMITMENT_KINDS = [
|
|
|
122
207
|
"deliverability_unit",
|
|
123
208
|
"linkedin_account",
|
|
124
209
|
"whatsapp_account",
|
|
210
|
+
// Repricing 2026-09 (S20, decisions 2.2 and 2.4). Priced in the pricing seed
|
|
211
|
+
// (seat.x_account, seat.phone_number) and reserve nothing until the
|
|
212
|
+
// repricing's effective-date switch.
|
|
213
|
+
"x_account",
|
|
214
|
+
"phone_number",
|
|
125
215
|
];
|
|
126
216
|
/**
|
|
127
217
|
* Sequencer platform fee per CONNECTED SENDING MAILBOX per month, in credits
|
|
128
218
|
* ($1.00). Charged on EVERY mailbox wired to the sequencer — BYOK/self-connected
|
|
129
219
|
* Gmail and Microsoft inboxes included — and it STACKS on Oxygen-sold mailboxes,
|
|
130
|
-
* which additionally pay their own mailbox/warmup/placement lines.
|
|
220
|
+
* which additionally pay their own mailbox/warmup/placement lines. Read from the
|
|
221
|
+
* pricing seed (`commitment.sending_mailbox`) through the generated snapshot, so
|
|
222
|
+
* there is one definition of the price.
|
|
131
223
|
*/
|
|
132
|
-
export const SENDING_MAILBOX_MONTHLY_CREDITS =
|
|
224
|
+
export const SENDING_MAILBOX_MONTHLY_CREDITS = SNAPSHOT_SENDING_MAILBOX_CREDITS_PER_MONTH;
|
|
133
225
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
226
|
+
* Grace window after a commitment goes past_due before the account disconnects.
|
|
227
|
+
* Repricing 2026-09 decision 2.8: sending from the account pauses at once, and
|
|
228
|
+
* after 7 days without funds the account disconnects (replacing the earlier 14
|
|
229
|
+
* days with no auto-cancel). The pause and the disconnect sweep are slice S23.
|
|
136
230
|
*/
|
|
137
|
-
export const
|
|
231
|
+
export const COMMITMENT_PAST_DUE_GRACE_DAYS = 7;
|
|
138
232
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
233
|
+
* P-15 (PROPOSED default, awaiting ratification): what "disconnect" means for a
|
|
234
|
+
* rented phone number. Releasing a number at the carrier is irreversible, so it is
|
|
235
|
+
* suspended at COMMITMENT_PAST_DUE_GRACE_DAYS (it already cannot dial while past
|
|
236
|
+
* due) and released only after this many days without funds.
|
|
143
237
|
*/
|
|
144
|
-
export const
|
|
238
|
+
export const PHONE_NUMBER_PAST_DUE_RELEASE_DAYS = 30;
|
|
239
|
+
const GRACE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
240
|
+
export function commitmentGraceSchedule(kind, pastDueSince) {
|
|
241
|
+
const graceEnds = new Date(pastDueSince.getTime() + COMMITMENT_PAST_DUE_GRACE_DAYS * GRACE_DAY_MS);
|
|
242
|
+
if (kind === "phone_number") {
|
|
243
|
+
return {
|
|
244
|
+
teardown: "release",
|
|
245
|
+
teardownAt: new Date(pastDueSince.getTime() + PHONE_NUMBER_PAST_DUE_RELEASE_DAYS * GRACE_DAY_MS),
|
|
246
|
+
suspendedAt: graceEnds,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { teardown: "disconnect", teardownAt: graceEnds, suspendedAt: null };
|
|
250
|
+
}
|
|
145
251
|
/**
|
|
146
252
|
* Reconnect window in which an ENDED commitment resumes instead of starting a new
|
|
147
253
|
* one. The single most important anti-double-charge rule: a Unipile re-auth, a
|
|
@@ -178,7 +284,10 @@ export function ratifiedCommitmentCredits(kind) {
|
|
|
178
284
|
case "sending_mailbox":
|
|
179
285
|
return SENDING_MAILBOX_MONTHLY_CREDITS;
|
|
180
286
|
case "managed_mailbox":
|
|
181
|
-
|
|
287
|
+
// The pricing seed's `commitment.managed_mailbox`, the one definition (S32).
|
|
288
|
+
// Before the 2026-09 switch the price in force is lower: resolve it with
|
|
289
|
+
// repricedChargeCredits from ./repricing.js.
|
|
290
|
+
return SNAPSHOT_MANAGED_MAILBOX_CREDITS_PER_MONTH;
|
|
182
291
|
// Ratified in @oxygen/control-db (MAILBOX_WARMUP_MONTHLY_CREDITS /
|
|
183
292
|
// DELIVERABILITY_UNIT_MONTHLY_CREDITS) since 2026-07-29, alongside the seat
|
|
184
293
|
// prices below. @oxygen/shared cannot import control-db, so these still
|
|
@@ -187,24 +296,75 @@ export function ratifiedCommitmentCredits(kind) {
|
|
|
187
296
|
case "mailbox_warmup":
|
|
188
297
|
case "deliverability_unit":
|
|
189
298
|
return null;
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
299
|
+
// Connected-account reservations are priced in the pricing seed
|
|
300
|
+
// (seat.*_account, seat.phone_number) and resolved, through the 2026-09
|
|
301
|
+
// repricing switch, by commitmentPrice in @oxygen/integrations.
|
|
193
302
|
case "linkedin_account":
|
|
194
303
|
case "whatsapp_account":
|
|
304
|
+
case "x_account":
|
|
305
|
+
case "phone_number":
|
|
195
306
|
return null;
|
|
196
307
|
}
|
|
197
308
|
}
|
|
198
|
-
|
|
309
|
+
/**
|
|
310
|
+
* The dollar packs sold at $1.25 per 100 credits, on offer until the 2026-09
|
|
311
|
+
* repricing's effective instant. The id is the pack's price in dollars.
|
|
312
|
+
*/
|
|
313
|
+
export const CREDIT_TOPUP_LEGACY_PACKS = [
|
|
199
314
|
{ id: "10", usdCents: 1_000, credits: 800 },
|
|
200
315
|
{ id: "25", usdCents: 2_500, credits: 2_000 },
|
|
201
316
|
{ id: "100", usdCents: 10_000, credits: 8_000 },
|
|
202
317
|
{ id: "250", usdCents: 25_000, credits: 20_000 },
|
|
203
318
|
];
|
|
319
|
+
/**
|
|
320
|
+
* The credit-first packs on offer from the 2026-09 repricing's instant, at $1.30
|
|
321
|
+
* per 100 credits (decision 1.5; the pack set is PROPOSED P-7). Dollar packs at
|
|
322
|
+
* $1.30 would buy fractional credits ($10 → 769.2), so these fix the credits and
|
|
323
|
+
* the id names them. The ids are new on purpose: fulfillment credits a pack
|
|
324
|
+
* session `pack.credits` without re-checking its amount, so reusing '10' or '25'
|
|
325
|
+
* with a new price would credit an old unpaid session from the new table.
|
|
326
|
+
*/
|
|
327
|
+
export const CREDIT_TOPUP_PACKS_2026_09 = [
|
|
328
|
+
{ id: "1000", usdCents: 1_300, credits: 1_000 },
|
|
329
|
+
{ id: "2000", usdCents: 2_600, credits: 2_000 },
|
|
330
|
+
{ id: "8000", usdCents: 10_400, credits: 8_000 },
|
|
331
|
+
{ id: "20000", usdCents: 26_000, credits: 20_000 },
|
|
332
|
+
];
|
|
333
|
+
/** Every pack id a checkout session may carry, whichever table sold it. */
|
|
334
|
+
export const CREDIT_TOPUP_KNOWN_PACKS = [
|
|
335
|
+
...CREDIT_TOPUP_LEGACY_PACKS,
|
|
336
|
+
...CREDIT_TOPUP_PACKS_2026_09,
|
|
337
|
+
];
|
|
338
|
+
/** The packs on offer now: the dollar packs until the switch, the credit-first packs from it. */
|
|
339
|
+
export function creditTopupPacks(options = {}) {
|
|
340
|
+
return isRepricingInForce(options) ? CREDIT_TOPUP_PACKS_2026_09 : CREDIT_TOPUP_LEGACY_PACKS;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Any pack a session may name, with the credits and price it was SOLD at. Used by
|
|
344
|
+
* fulfillment, which must credit an old session its original credits whichever
|
|
345
|
+
* packs are on offer when Stripe confirms the payment.
|
|
346
|
+
*/
|
|
204
347
|
export function resolveCreditTopupPack(id) {
|
|
205
348
|
if (!id)
|
|
206
349
|
return null;
|
|
207
|
-
return
|
|
350
|
+
return CREDIT_TOPUP_KNOWN_PACKS.find((pack) => pack.id === id) ?? null;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* How the top-up route sells a pack id right now. A pack on offer sells at its
|
|
354
|
+
* own price. Any other known id (a dollar pack after the switch from an older
|
|
355
|
+
* CLI, or a credit-first pack before it) still works and buys the pack's credits
|
|
356
|
+
* as a custom amount at the rate in force, so an old client sees the current
|
|
357
|
+
* quote and never an old price. Null for an unknown id.
|
|
358
|
+
*/
|
|
359
|
+
export function quoteCreditTopupPack(id, options = {}) {
|
|
360
|
+
const pack = resolveCreditTopupPack(id);
|
|
361
|
+
if (!pack)
|
|
362
|
+
return null;
|
|
363
|
+
const offered = creditTopupPacks(options).some((candidate) => candidate.id === pack.id);
|
|
364
|
+
if (offered)
|
|
365
|
+
return { pack, offered, credits: pack.credits, usdCents: pack.usdCents };
|
|
366
|
+
const usdCents = creditTopupUsdCents(pack.credits, options);
|
|
367
|
+
return usdCents == null ? null : { pack, offered, credits: pack.credits, usdCents };
|
|
208
368
|
}
|
|
209
369
|
// Card-required free trial: every new signup chooses a self-serve Stripe plan,
|
|
210
370
|
// gets TRIAL_CREDIT_GRANT credits (a value-demonstration budget sized to the
|
|
@@ -290,6 +450,86 @@ function planPricing(planKey) {
|
|
|
290
450
|
byokEnabled: row.byokEnabled,
|
|
291
451
|
};
|
|
292
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* The volume bonus of a plan (repricing 2026-09, decision 1.3): the monthly
|
|
455
|
+
* credits it grants above face value ($1 = 100 credits). +5% on $499 (52,395),
|
|
456
|
+
* +10% on $999 (109,890), +15% on $1,999 (229,885); $49-$199 stay flat.
|
|
457
|
+
*
|
|
458
|
+
* DERIVED from the seeded monthlyCredits rather than declared, so the schedule
|
|
459
|
+
* lives in one place (packages/control-db/src/pricing/seed.ts). Only the
|
|
460
|
+
* purchasable Oxygen rungs carry a bonus: a legacy plan whose credits exceed its
|
|
461
|
+
* price (launch_50 bought 20,000 credits for $50 under the old unit) is not a
|
|
462
|
+
* volume bonus, and a contact-sales plan has no face value.
|
|
463
|
+
*/
|
|
464
|
+
export function planVolumeBonusCredits(planKey) {
|
|
465
|
+
if (!planKey || !OXYGEN_PLAN_KEYS.includes(planKey))
|
|
466
|
+
return 0;
|
|
467
|
+
const { monthlyPriceCents, monthlyCredits } = planPricing(planKey);
|
|
468
|
+
if (monthlyPriceCents === null || monthlyCredits === null)
|
|
469
|
+
return 0;
|
|
470
|
+
const faceValueCredits = Math.round((monthlyPriceCents / 100) * CREDITS_PER_USD);
|
|
471
|
+
return Math.max(0, monthlyCredits - faceValueCredits);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The volume bonus inside one subscription grant of `monthlyCredits` on
|
|
475
|
+
* `planKey`: the plan's bonus when the grant is the plan's seeded allowance, and
|
|
476
|
+
* 0 when a metadata override replaced it (a custom allowance carries no
|
|
477
|
+
* schedule to defer). Grant writers pass this to grantSubscriptionCredits.
|
|
478
|
+
*/
|
|
479
|
+
export function subscriptionGrantVolumeBonusCredits(planKey, monthlyCredits) {
|
|
480
|
+
if (!planKey || typeof monthlyCredits !== "number")
|
|
481
|
+
return 0;
|
|
482
|
+
const bonus = planVolumeBonusCredits(planKey);
|
|
483
|
+
return bonus > 0 && planPricing(planKey).monthlyCredits === monthlyCredits ? bonus : 0;
|
|
484
|
+
}
|
|
485
|
+
/** The volume bonus as a whole percentage of face value: 5, 10, 15, or 0. */
|
|
486
|
+
export function planVolumeBonusPercent(planKey) {
|
|
487
|
+
const bonus = planVolumeBonusCredits(planKey);
|
|
488
|
+
if (bonus === 0 || !planKey)
|
|
489
|
+
return 0;
|
|
490
|
+
const faceValueCredits = Math.round(((planPricing(planKey).monthlyPriceCents ?? 0) / 100) * CREDITS_PER_USD);
|
|
491
|
+
return faceValueCredits > 0 ? Math.round((bonus / faceValueCredits) * 100) : 0;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Agency partner bonus (repricing 2026-09, decision 9.1): a partner earns
|
|
495
|
+
* +15% of its plan's face value in bonus credits every month, on every Oxygen
|
|
496
|
+
* size from $99, on top of the volume bonus. The two percentages ADD (spec
|
|
497
|
+
* PROPOSED default P-1, option A of the agency-partner benchmark): the $1,999
|
|
498
|
+
* plan grants 229,885 + 29,985 = 259,870, never 229,885 x 1.15.
|
|
499
|
+
*
|
|
500
|
+
* The bonus is its own ledger line (category `partner_bonus`), never part of
|
|
501
|
+
* the plan's monthlyCredits, so it stays out of every limit or guard derived
|
|
502
|
+
* from the plan allowance (P-3, P-54, P-61).
|
|
503
|
+
*/
|
|
504
|
+
export const PARTNER_BONUS_PERCENT = 15;
|
|
505
|
+
/** The smallest plan that earns the partner bonus: $99 a month. */
|
|
506
|
+
export const PARTNER_BONUS_MINIMUM_MONTHLY_PRICE_CENTS = 9_900;
|
|
507
|
+
/**
|
|
508
|
+
* The monthly partner bonus of a plan: 15% of face value ($1 = 100 credits),
|
|
509
|
+
* rounded half up to a whole credit (section 2's rounding rule). 0 for Free,
|
|
510
|
+
* $49, the grandfathered Starter/Pro/Team and older legacy plans, and the
|
|
511
|
+
* contact-sales plans (P-3: Oxygen sizes only).
|
|
512
|
+
*/
|
|
513
|
+
export function planPartnerBonusCredits(planKey) {
|
|
514
|
+
if (!planKey || !OXYGEN_PLAN_KEYS.includes(planKey))
|
|
515
|
+
return 0;
|
|
516
|
+
const { monthlyPriceCents } = planPricing(planKey);
|
|
517
|
+
if (monthlyPriceCents === null || monthlyPriceCents < PARTNER_BONUS_MINIMUM_MONTHLY_PRICE_CENTS)
|
|
518
|
+
return 0;
|
|
519
|
+
const faceValueCredits = Math.round((monthlyPriceCents / 100) * CREDITS_PER_USD);
|
|
520
|
+
return Math.round((faceValueCredits * PARTNER_BONUS_PERCENT) / 100);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* The partner bonus that rides with one subscription grant of `monthlyCredits`
|
|
524
|
+
* on `planKey`: the plan's bonus when the grant is the plan's seeded allowance,
|
|
525
|
+
* 0 when a metadata override replaced it (a custom contract is priced by hand).
|
|
526
|
+
*/
|
|
527
|
+
export function subscriptionGrantPartnerBonusCredits(planKey, monthlyCredits) {
|
|
528
|
+
if (!planKey || typeof monthlyCredits !== "number")
|
|
529
|
+
return 0;
|
|
530
|
+
const bonus = planPartnerBonusCredits(planKey);
|
|
531
|
+
return bonus > 0 && planPricing(planKey).monthlyCredits === monthlyCredits ? bonus : 0;
|
|
532
|
+
}
|
|
293
533
|
/**
|
|
294
534
|
* The monthly-credit feature bullet, DERIVED rather than written down.
|
|
295
535
|
*
|
|
@@ -302,8 +542,12 @@ function monthlyCreditsFeature(planKey) {
|
|
|
302
542
|
const credits = planPricing(planKey).monthlyCredits;
|
|
303
543
|
if (credits === null)
|
|
304
544
|
return "Custom monthly credits";
|
|
305
|
-
|
|
545
|
+
const bonusPercent = planVolumeBonusPercent(planKey);
|
|
546
|
+
return bonusPercent > 0
|
|
547
|
+
? `${credits.toLocaleString("en-US")} credits every month (${bonusPercent}% volume bonus)`
|
|
548
|
+
: `${credits.toLocaleString("en-US")} credits every month`;
|
|
306
549
|
}
|
|
550
|
+
const BRING_YOUR_OWN_KEYS_FEATURE = "Bring your own keys";
|
|
307
551
|
export const BASE_PRICING_PLANS = {
|
|
308
552
|
// The ENTRY tier since 2026-08-16, superseding the 2026-07-22 retirement that
|
|
309
553
|
// left this as the unentitled fallback for churned / never-subscribed orgs.
|
|
@@ -327,10 +571,14 @@ export const BASE_PRICING_PLANS = {
|
|
|
327
571
|
monthlyAutomationActions: null,
|
|
328
572
|
automationOverageCentsPerMillion: null,
|
|
329
573
|
automationOverageEnabledDefault: false,
|
|
330
|
-
|
|
574
|
+
// Static text cannot read the credit gate, and once it is in force a free
|
|
575
|
+
// workspace may connect senders, publish and buy managed email on credits
|
|
576
|
+
// (repricing 2026-09, S22). So it names only what needs a plan either way;
|
|
577
|
+
// the gate-aware walls speak for the rest.
|
|
578
|
+
description: "Your CRM and tables — free forever. Credits pay for enrichment, AI and workflow steps; your own provider keys need a paid plan.",
|
|
331
579
|
ctaLabel: "Upgrade",
|
|
332
580
|
features: [
|
|
333
|
-
"CRM, Tables,
|
|
581
|
+
"CRM, Tables, and Knowledge — unmetered; workflows pay per step",
|
|
334
582
|
"Sequences: author, preview, and enroll",
|
|
335
583
|
"Agents and Workspace Copilot",
|
|
336
584
|
"Credits for enrichment and AI, topped up any time",
|
|
@@ -355,7 +603,7 @@ export const BASE_PRICING_PLANS = {
|
|
|
355
603
|
monthlyCreditsFeature("oxygen_49"),
|
|
356
604
|
"All integrations",
|
|
357
605
|
"Workflows",
|
|
358
|
-
|
|
606
|
+
BRING_YOUR_OWN_KEYS_FEATURE,
|
|
359
607
|
"Standard support",
|
|
360
608
|
],
|
|
361
609
|
},
|
|
@@ -518,14 +766,15 @@ export function resolveSubscriptionEntitlement(input) {
|
|
|
518
766
|
: { entitled: true, reason: "trialing", graceEndsAt: null };
|
|
519
767
|
}
|
|
520
768
|
if (input.status === "past_due") {
|
|
521
|
-
|
|
769
|
+
const graceStartsAt = pastDueGraceStart(input, now);
|
|
770
|
+
if (!graceStartsAt) {
|
|
522
771
|
return {
|
|
523
772
|
entitled: false,
|
|
524
773
|
reason: "missing_past_due_period_start",
|
|
525
774
|
graceEndsAt: null,
|
|
526
775
|
};
|
|
527
776
|
}
|
|
528
|
-
const graceEndsAt = new Date(
|
|
777
|
+
const graceEndsAt = new Date(graceStartsAt.getTime() + PAST_DUE_GRACE_DAYS * 24 * 60 * 60 * 1000);
|
|
529
778
|
return graceEndsAt.getTime() > now.getTime()
|
|
530
779
|
? { entitled: true, reason: "past_due_grace", graceEndsAt }
|
|
531
780
|
: { entitled: false, reason: "past_due_grace_expired", graceEndsAt };
|
|
@@ -539,6 +788,35 @@ export function resolveSubscriptionEntitlement(input) {
|
|
|
539
788
|
}
|
|
540
789
|
return { entitled: false, reason: "missing_subscription", graceEndsAt: null };
|
|
541
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* Where a past-due subscription's grace starts (OXP-43.4).
|
|
793
|
+
*
|
|
794
|
+
* The grace used to count from `currentPeriodStart`. For a monthly plan that is
|
|
795
|
+
* the renewal whose payment failed, but a yearly plan's period start can be
|
|
796
|
+
* months back, so a yearly subscriber who went past due mid-year lost access at
|
|
797
|
+
* once. The grace now counts from `pastDueSince`, the instant the unpaid streak
|
|
798
|
+
* was first seen. A stamp older than the current period start is left over from
|
|
799
|
+
* an earlier streak, so the period start wins over it, which keeps a monthly
|
|
800
|
+
* plan's grace at least as long as it always was. A row without a stamp
|
|
801
|
+
* (written before the column existed) counts from the monthly credit slice
|
|
802
|
+
* `now` falls in: its period start for a monthly plan, exactly as before.
|
|
803
|
+
*/
|
|
804
|
+
function pastDueGraceStart(input, now) {
|
|
805
|
+
const periodStart = validDateOrNull(input.currentPeriodStart);
|
|
806
|
+
const pastDueSince = validDateOrNull(input.pastDueSince);
|
|
807
|
+
if (pastDueSince) {
|
|
808
|
+
return periodStart && periodStart.getTime() > pastDueSince.getTime()
|
|
809
|
+
? periodStart
|
|
810
|
+
: pastDueSince;
|
|
811
|
+
}
|
|
812
|
+
const periodEnd = validDateOrNull(input.currentPeriodEnd);
|
|
813
|
+
return periodStart && periodEnd
|
|
814
|
+
? dueSubscriptionGrantSlices(periodStart, periodEnd, now).at(-1)?.start ?? periodStart
|
|
815
|
+
: periodStart;
|
|
816
|
+
}
|
|
817
|
+
function validDateOrNull(value) {
|
|
818
|
+
return value instanceof Date && !Number.isNaN(value.getTime()) ? value : null;
|
|
819
|
+
}
|
|
542
820
|
/**
|
|
543
821
|
* The six purchasable rungs, keyed by plan key.
|
|
544
822
|
*
|
|
@@ -566,10 +844,31 @@ function oxygenRung(planKey) {
|
|
|
566
844
|
ctaLabel: dollars === null ? "Get started" : `Choose $${dollars.toLocaleString("en-US")}`,
|
|
567
845
|
features: [
|
|
568
846
|
monthlyCreditsFeature(planKey),
|
|
569
|
-
|
|
847
|
+
// A rung without BYOK in the seed ($49 since the 2026-09 repricing) must not
|
|
848
|
+
// promise it. The card follows the seed, not the S07 hold below: it
|
|
849
|
+
// under-promises to a $49 customer for the notice period rather than
|
|
850
|
+
// advertising an entitlement that ends at the effective date.
|
|
851
|
+
...BASE_PRICING_PLANS.oxygen.features
|
|
852
|
+
.slice(1)
|
|
853
|
+
.filter((feature) => pricing.byokEnabled || feature !== BRING_YOUR_OWN_KEYS_FEATURE),
|
|
570
854
|
],
|
|
571
855
|
};
|
|
572
856
|
}
|
|
857
|
+
/**
|
|
858
|
+
* The plan in force for `planKey` right now: the catalog row with every value the
|
|
859
|
+
* 2026-09 repricing holds back until its effective date put back.
|
|
860
|
+
*
|
|
861
|
+
* Only the $49 plan's BYOK entitlement is held here (S22, decision 2.7): the seed
|
|
862
|
+
* and snapshot already say `false`, and until the switch's instant the plan keeps
|
|
863
|
+
* the `true` it was sold with. The representative `oxygen` row carries the $49
|
|
864
|
+
* numbers, so it is held the same way.
|
|
865
|
+
*/
|
|
866
|
+
function withRepricingHolds(planKey, plan, options = {}) {
|
|
867
|
+
if (planKey !== "oxygen_49" && planKey !== "oxygen")
|
|
868
|
+
return plan;
|
|
869
|
+
const byokEnabled = repricedValue(OXYGEN_49_BYOK_REPRICING, options);
|
|
870
|
+
return byokEnabled === plan.byokEnabled ? plan : { ...plan, byokEnabled };
|
|
871
|
+
}
|
|
573
872
|
const LEGACY_PRICING_PLAN_OVERRIDES = {
|
|
574
873
|
launch_50: {
|
|
575
874
|
...BASE_PRICING_PLANS.starter,
|
|
@@ -617,7 +916,7 @@ const LEGACY_PRICING_PLAN_OVERRIDES = {
|
|
|
617
916
|
description: "Legacy Scale plan.",
|
|
618
917
|
},
|
|
619
918
|
};
|
|
620
|
-
export function resolveBasePricingPlan(tier) {
|
|
919
|
+
export function resolveBasePricingPlan(tier, options = {}) {
|
|
621
920
|
if (!tier)
|
|
622
921
|
return null;
|
|
623
922
|
// Plan KEYS before tiers: `subscriptions.tier` stores "oxygen_499", and the
|
|
@@ -625,9 +924,9 @@ export function resolveBasePricingPlan(tier) {
|
|
|
625
924
|
// customer paid for.
|
|
626
925
|
const oxygen = OXYGEN_PLAN_OVERRIDES[tier];
|
|
627
926
|
if (oxygen)
|
|
628
|
-
return oxygen;
|
|
927
|
+
return withRepricingHolds(tier, oxygen, options);
|
|
629
928
|
if (tier in BASE_PRICING_PLANS) {
|
|
630
|
-
return BASE_PRICING_PLANS[tier];
|
|
929
|
+
return withRepricingHolds(tier, BASE_PRICING_PLANS[tier], options);
|
|
631
930
|
}
|
|
632
931
|
const legacy = LEGACY_PRICING_PLAN_OVERRIDES[tier];
|
|
633
932
|
return legacy ?? null;
|
|
@@ -751,6 +1050,50 @@ export function getCurrentBillingCycleKey(date = new Date()) {
|
|
|
751
1050
|
* billing-anchor shift without admitting a quarterly or annual period.
|
|
752
1051
|
*/
|
|
753
1052
|
export const CREDIT_CYCLE_MAX_PERIOD_DAYS = 35;
|
|
1053
|
+
/**
|
|
1054
|
+
* Annual billing (Repricing 2026-09, decision 1.2): a year costs ten months of
|
|
1055
|
+
* the monthly price, and the credits are still granted monthly.
|
|
1056
|
+
*/
|
|
1057
|
+
export const ANNUAL_BILLING_MONTHS_CHARGED = 10;
|
|
1058
|
+
/** The yearly price of a rung, in cents: ten times its monthly price. */
|
|
1059
|
+
export function annualPlanPriceCents(monthlyPriceCents) {
|
|
1060
|
+
return monthlyPriceCents * ANNUAL_BILLING_MONTHS_CHARGED;
|
|
1061
|
+
}
|
|
1062
|
+
export const PLAN_BILLING_INTERVALS = ["month", "year"];
|
|
1063
|
+
/**
|
|
1064
|
+
* A billing interval as a customer or an agent types it. `month`/`monthly` and
|
|
1065
|
+
* `year`/`yearly`/`annual`/`annually` are accepted; anything else is null so the
|
|
1066
|
+
* caller can refuse it by name instead of silently billing monthly.
|
|
1067
|
+
*/
|
|
1068
|
+
export function parsePlanBillingInterval(value) {
|
|
1069
|
+
if (typeof value !== "string")
|
|
1070
|
+
return null;
|
|
1071
|
+
const normalized = value.trim().toLowerCase();
|
|
1072
|
+
if (normalized === "month" || normalized === "monthly")
|
|
1073
|
+
return "month";
|
|
1074
|
+
if (normalized === "year"
|
|
1075
|
+
|| normalized === "yearly"
|
|
1076
|
+
|| normalized === "annual"
|
|
1077
|
+
|| normalized === "annually") {
|
|
1078
|
+
return "year";
|
|
1079
|
+
}
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* What one charge of a rung costs on an interval, in cents: the monthly price,
|
|
1084
|
+
* or ten months' price for a year.
|
|
1085
|
+
*/
|
|
1086
|
+
export function planChargeCents(monthlyPriceCents, interval) {
|
|
1087
|
+
return interval === "year" ? annualPlanPriceCents(monthlyPriceCents) : monthlyPriceCents;
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* The per-month equivalent of one charge, in cents, rounded to the cent: $990 a
|
|
1091
|
+
* year is $82.50 a month. This is what `recurring_monthly` reports, so a yearly
|
|
1092
|
+
* plan is never compared as if it cost $990 a month.
|
|
1093
|
+
*/
|
|
1094
|
+
export function planMonthlyEquivalentCents(chargeCents, interval) {
|
|
1095
|
+
return interval === "year" ? Math.round(chargeCents / 12) : chargeCents;
|
|
1096
|
+
}
|
|
754
1097
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
755
1098
|
function startOfUtcMonth(date) {
|
|
756
1099
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
|
@@ -800,6 +1143,22 @@ export function isStripeManagedSubscription(subscription) {
|
|
|
800
1143
|
&& metadata.off_stripe !== "true"
|
|
801
1144
|
&& metadata.synthetic !== true);
|
|
802
1145
|
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Whether a Stripe subscription is scheduled to end. A classic-billing-mode
|
|
1148
|
+
* period-end cancel sets `cancel_at_period_end`; a Billing Portal cancel on a
|
|
1149
|
+
* flexible-billing-mode subscription sets only `cancel_at` (to the period end)
|
|
1150
|
+
* and leaves `cancel_at_period_end` false (Stripe billing-mode comparison docs;
|
|
1151
|
+
* reproduced in test mode 2026-09-26). Reading the boolean alone showed a
|
|
1152
|
+
* Portal-cancelled plan as renewing. `cancel_at` on a subscription that has
|
|
1153
|
+
* already ended records when it ended, not a pending cancellation, so it only
|
|
1154
|
+
* counts while the subscription is still live.
|
|
1155
|
+
*/
|
|
1156
|
+
export function stripeSubscriptionCancellationScheduled(subscription) {
|
|
1157
|
+
if (subscription.cancel_at_period_end === true)
|
|
1158
|
+
return true;
|
|
1159
|
+
const ended = subscription.status === "canceled" || subscription.status === "incomplete_expired";
|
|
1160
|
+
return !ended && subscription.cancel_at != null;
|
|
1161
|
+
}
|
|
803
1162
|
/**
|
|
804
1163
|
* The window the credit-allowance bar measures: "this month's credits", resolved
|
|
805
1164
|
* to the boundary at which the plan's grant actually renews.
|
|
@@ -822,6 +1181,20 @@ export function resolveCreditCycleWindow(input) {
|
|
|
822
1181
|
const subscription = input.subscription;
|
|
823
1182
|
const start = subscription?.currentPeriodStart ?? null;
|
|
824
1183
|
const end = subscription?.currentPeriodEnd ?? null;
|
|
1184
|
+
if (subscription?.billingInterval === "year"
|
|
1185
|
+
&& !subscription.offStripe
|
|
1186
|
+
&& start != null
|
|
1187
|
+
&& end != null) {
|
|
1188
|
+
const slice = currentSubscriptionGrantSlice(start, end, now);
|
|
1189
|
+
if (slice) {
|
|
1190
|
+
return {
|
|
1191
|
+
start: slice.start,
|
|
1192
|
+
end: slice.end,
|
|
1193
|
+
source: "subscription_period",
|
|
1194
|
+
label: `${formatUtcDay(slice.start)} – ${formatUtcDay(slice.end)}`,
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
825
1198
|
const usesPeriod = subscription != null
|
|
826
1199
|
&& !subscription.offStripe
|
|
827
1200
|
&& start != null
|