@oxygen-agent/cli 1.1010.644 → 1.1010.721
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/command-manifest.js +1 -1
- package/dist/inbox-needs-reply-notice.d.ts +12 -0
- package/dist/inbox-needs-reply-notice.js +51 -0
- package/dist/index.js +186 -45
- package/dist/skills.js +48 -22
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +33 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +67 -2
- package/node_modules/@oxygen/shared/dist/billing.d.ts +63 -9
- package/node_modules/@oxygen/shared/dist/billing.js +96 -14
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +11 -1
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +25 -0
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +6 -1
- package/node_modules/@oxygen/shared/dist/feature-gates.js +7 -1
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/index.js +2 -1
- 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/otlp-log-sink.js +19 -2
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +118 -0
- package/node_modules/@oxygen/shared/dist/plan-band.js +147 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +131 -120
- package/node_modules/@oxygen/shared/dist/plan-limits.js +80 -71
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +79 -14
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +61 -12
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +4 -3
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +9 -3
- 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/repricing.d.ts +130 -0
- package/node_modules/@oxygen/shared/dist/repricing.js +320 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.js +49 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +58 -1
- package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +39 -10
- package/node_modules/@oxygen/shared/dist/table-capacity.js +68 -4
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +36 -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/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- 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
package/dist/skills.js
CHANGED
|
@@ -397,21 +397,36 @@ async function stageAuthenticatedSkills(dir, source, targets, fetchImpl) {
|
|
|
397
397
|
// installer reads a skill's files from `<root>/<name>/<file>`.
|
|
398
398
|
const skillsRoot = source.index_url.replace(/\/index\.json$/i, "");
|
|
399
399
|
for (const entry of wanted) {
|
|
400
|
-
//
|
|
401
|
-
// sibling fetches
|
|
402
|
-
// dir and a late mkdirSync would silently recreate it as orphan litter.
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
400
|
+
// Every worker settles before we throw: on a failed fetch, rejecting early
|
|
401
|
+
// would leave sibling fetches still writing — the caller's finally rmSyncs the
|
|
402
|
+
// temp dir and a late mkdirSync would silently recreate it as orphan litter.
|
|
403
|
+
// Bounded, not one fetch per file: oxygen-gtm alone is ~80 files, and that
|
|
404
|
+
// burst against the self-hosted production web reset connections, failing
|
|
405
|
+
// roughly one install in three with a bare "fetch failed" (2026-09-26).
|
|
406
|
+
let failure;
|
|
407
|
+
let next = 0;
|
|
408
|
+
await Promise.all(Array.from({ length: Math.min(entry.files.length, STAGED_FILE_CONCURRENCY) }, async () => {
|
|
409
|
+
while (failure === undefined && next < entry.files.length) {
|
|
410
|
+
const file = entry.files[next++];
|
|
411
|
+
try {
|
|
412
|
+
const content = await fetchStagedSkillFile(`${skillsRoot}/${entry.name}/${file}`, fetchImpl);
|
|
413
|
+
const destination = join(dir, entry.name, ...file.split("/"));
|
|
414
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
415
|
+
writeFileSync(destination, content);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
failure ??= error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
409
421
|
}));
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
throw failed.reason;
|
|
422
|
+
if (failure !== undefined)
|
|
423
|
+
throw failure;
|
|
413
424
|
}
|
|
414
425
|
}
|
|
426
|
+
const STAGED_FILE_CONCURRENCY = 6;
|
|
427
|
+
const STAGED_FILE_TIMEOUT_MS = 30_000;
|
|
428
|
+
/** Backoff before each retry of a transient failure; its length is the retry budget. */
|
|
429
|
+
const STAGED_FILE_RETRY_DELAYS_MS = [250, 1_000];
|
|
415
430
|
async function fetchStagedSkillEntries(indexUrl, fetchImpl) {
|
|
416
431
|
let response;
|
|
417
432
|
try {
|
|
@@ -444,18 +459,29 @@ async function fetchStagedSkillEntries(indexUrl, fetchImpl) {
|
|
|
444
459
|
}
|
|
445
460
|
return entries;
|
|
446
461
|
}
|
|
462
|
+
/** A transport failure, a timeout, 429 or 5xx is retried; any other status is final. */
|
|
447
463
|
async function fetchStagedSkillFile(url, fetchImpl) {
|
|
448
|
-
let
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
464
|
+
for (let attempt = 0;; attempt += 1) {
|
|
465
|
+
let details;
|
|
466
|
+
try {
|
|
467
|
+
const response = await fetchImpl(url, { signal: AbortSignal.timeout(STAGED_FILE_TIMEOUT_MS) });
|
|
468
|
+
if (response.ok)
|
|
469
|
+
return await response.text();
|
|
470
|
+
if (response.status !== 429 && response.status < 500) {
|
|
471
|
+
throw stagingFailure("fetch_file", { url, status: response.status });
|
|
472
|
+
}
|
|
473
|
+
details = { url, status: response.status };
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (error instanceof OxygenError)
|
|
477
|
+
throw error;
|
|
478
|
+
details = { url, message: errorMessage(error) };
|
|
479
|
+
}
|
|
480
|
+
const delay = STAGED_FILE_RETRY_DELAYS_MS[attempt];
|
|
481
|
+
if (delay === undefined)
|
|
482
|
+
throw stagingFailure("fetch_file", { ...details, attempts: attempt + 1 });
|
|
483
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
457
484
|
}
|
|
458
|
-
return response.text();
|
|
459
485
|
}
|
|
460
486
|
// Mirror the installer-failure error so a staging failure surfaces identically
|
|
461
487
|
// (code, message, exitCode) to the caller — never as a silent URL fallback.
|
|
@@ -45,8 +45,10 @@ export declare function occurrencesInPeriod(from: Date, to: Date, anchorDay: num
|
|
|
45
45
|
* The block is a STANDING one-month-per-resource reserve (see
|
|
46
46
|
* credit-commitments.ts): because every anchor is at most one month out, holding
|
|
47
47
|
* one month per active resource always covers every fixed charge falling due
|
|
48
|
-
* inside any coming subscription period of <= 1 month. Longer periods
|
|
49
|
-
*
|
|
48
|
+
* inside any coming subscription period of <= 1 month. Longer periods need
|
|
49
|
+
* proportionally more held, or the block runs dry mid-term. A yearly plan is
|
|
50
|
+
* not such a period: it grants monthly slices, so its caller passes the current
|
|
51
|
+
* slice's length (see recomputeOrgCommitmentBlock, P-23).
|
|
50
52
|
*
|
|
51
53
|
* Returns at least 1 — a missing/zero/garbage period length must never collapse
|
|
52
54
|
* the block to nothing, which would silently disable the whole feature.
|
|
@@ -58,3 +60,32 @@ export declare function blockPeriodsForSubscription(periodDays: number | null |
|
|
|
58
60
|
* the millisecond math (and get it wrong across DST by using local dates).
|
|
59
61
|
*/
|
|
60
62
|
export declare function periodLengthDays(start: Date | null | undefined, end: Date | null | undefined): number | null;
|
|
63
|
+
/** Longest period that is still ONE monthly grant (matches the allowance-bar guard). */
|
|
64
|
+
export declare const SINGLE_GRANT_PERIOD_MAX_DAYS = 35;
|
|
65
|
+
export type SubscriptionGrantSlice = {
|
|
66
|
+
/** 0 for the slice the paid invoice starts; the grant key of slice 0 is the period key. */
|
|
67
|
+
index: number;
|
|
68
|
+
start: Date;
|
|
69
|
+
/** Exclusive. The last slice always ends exactly at the period end. */
|
|
70
|
+
end: Date;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The monthly grant slices of one paid subscription period.
|
|
74
|
+
*
|
|
75
|
+
* A period of at most {@link SINGLE_GRANT_PERIOD_MAX_DAYS} days is a single
|
|
76
|
+
* slice covering the whole period, which is exactly today's monthly grant. A
|
|
77
|
+
* longer period is cut on its start's anchor day. The slice count is bounded by
|
|
78
|
+
* the period's length in average months (12 for a year, including leap years
|
|
79
|
+
* and clamped anchors), and the last slice absorbs any remainder, so a period
|
|
80
|
+
* end whose time of day differs from its start by seconds can never produce a
|
|
81
|
+
* thirteenth sliver.
|
|
82
|
+
*/
|
|
83
|
+
export declare function subscriptionGrantSlices(periodStart: Date, periodEnd: Date): SubscriptionGrantSlice[];
|
|
84
|
+
/**
|
|
85
|
+
* The slices of a period that have started by `now`, in order. Nothing after the
|
|
86
|
+
* period end is ever due: a subscription set to cancel at period end, or a row
|
|
87
|
+
* whose renewal webhook has not arrived yet, stops at its last paid slice.
|
|
88
|
+
*/
|
|
89
|
+
export declare function dueSubscriptionGrantSlices(periodStart: Date, periodEnd: Date, now: Date): SubscriptionGrantSlice[];
|
|
90
|
+
/** The slice `now` falls in, or null outside the period. */
|
|
91
|
+
export declare function currentSubscriptionGrantSlice(periodStart: Date, periodEnd: Date, now: Date): SubscriptionGrantSlice | null;
|
|
@@ -108,8 +108,10 @@ export function occurrencesInPeriod(from, to, anchorDay) {
|
|
|
108
108
|
* The block is a STANDING one-month-per-resource reserve (see
|
|
109
109
|
* credit-commitments.ts): because every anchor is at most one month out, holding
|
|
110
110
|
* one month per active resource always covers every fixed charge falling due
|
|
111
|
-
* inside any coming subscription period of <= 1 month. Longer periods
|
|
112
|
-
*
|
|
111
|
+
* inside any coming subscription period of <= 1 month. Longer periods need
|
|
112
|
+
* proportionally more held, or the block runs dry mid-term. A yearly plan is
|
|
113
|
+
* not such a period: it grants monthly slices, so its caller passes the current
|
|
114
|
+
* slice's length (see recomputeOrgCommitmentBlock, P-23).
|
|
113
115
|
*
|
|
114
116
|
* Returns at least 1 — a missing/zero/garbage period length must never collapse
|
|
115
117
|
* the block to nothing, which would silently disable the whole feature.
|
|
@@ -133,3 +135,66 @@ export function periodLengthDays(start, end) {
|
|
|
133
135
|
return null;
|
|
134
136
|
return Math.floor(ms / DAY_MS);
|
|
135
137
|
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Monthly credit slices of a longer (annual) subscription period
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
//
|
|
142
|
+
// Repricing 2026-09, decision 1.2: every plan size can be paid annually, and the
|
|
143
|
+
// credits are STILL granted monthly. One paid annual period therefore funds a
|
|
144
|
+
// sequence of monthly grant slices. P-8 (PROPOSED default) anchors the slices to
|
|
145
|
+
// the subscription period start, walked with the same immutable-anchor clamp as
|
|
146
|
+
// the commitment anchors above: a period starting 31 Jan slices at 28 Feb,
|
|
147
|
+
// 31 Mar, 30 Apr ... and never decays to the 28th.
|
|
148
|
+
/** Average Gregorian month length in days: 365.2425 / 12. */
|
|
149
|
+
const AVERAGE_MONTH_DAYS = 365.2425 / 12;
|
|
150
|
+
/** Longest period that is still ONE monthly grant (matches the allowance-bar guard). */
|
|
151
|
+
export const SINGLE_GRANT_PERIOD_MAX_DAYS = 35;
|
|
152
|
+
/**
|
|
153
|
+
* The monthly grant slices of one paid subscription period.
|
|
154
|
+
*
|
|
155
|
+
* A period of at most {@link SINGLE_GRANT_PERIOD_MAX_DAYS} days is a single
|
|
156
|
+
* slice covering the whole period, which is exactly today's monthly grant. A
|
|
157
|
+
* longer period is cut on its start's anchor day. The slice count is bounded by
|
|
158
|
+
* the period's length in average months (12 for a year, including leap years
|
|
159
|
+
* and clamped anchors), and the last slice absorbs any remainder, so a period
|
|
160
|
+
* end whose time of day differs from its start by seconds can never produce a
|
|
161
|
+
* thirteenth sliver.
|
|
162
|
+
*/
|
|
163
|
+
export function subscriptionGrantSlices(periodStart, periodEnd) {
|
|
164
|
+
if (!(periodStart instanceof Date) || !(periodEnd instanceof Date))
|
|
165
|
+
return [];
|
|
166
|
+
if (Number.isNaN(periodStart.getTime()) || Number.isNaN(periodEnd.getTime()))
|
|
167
|
+
return [];
|
|
168
|
+
if (periodEnd <= periodStart)
|
|
169
|
+
return [];
|
|
170
|
+
const periodDays = (periodEnd.getTime() - periodStart.getTime()) / DAY_MS;
|
|
171
|
+
if (periodDays <= SINGLE_GRANT_PERIOD_MAX_DAYS) {
|
|
172
|
+
return [{ index: 0, start: periodStart, end: periodEnd }];
|
|
173
|
+
}
|
|
174
|
+
const maxSlices = Math.max(1, Math.round(periodDays / AVERAGE_MONTH_DAYS));
|
|
175
|
+
const anchorDay = anchorDayOf(periodStart);
|
|
176
|
+
const slices = [];
|
|
177
|
+
let cursor = periodStart;
|
|
178
|
+
for (let index = 0; index < maxSlices; index += 1) {
|
|
179
|
+
const next = nextAnchorDueAt(cursor, anchorDay);
|
|
180
|
+
if (index === maxSlices - 1 || next >= periodEnd) {
|
|
181
|
+
slices.push({ index, start: cursor, end: periodEnd });
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
slices.push({ index, start: cursor, end: next });
|
|
185
|
+
cursor = next;
|
|
186
|
+
}
|
|
187
|
+
return slices;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The slices of a period that have started by `now`, in order. Nothing after the
|
|
191
|
+
* period end is ever due: a subscription set to cancel at period end, or a row
|
|
192
|
+
* whose renewal webhook has not arrived yet, stops at its last paid slice.
|
|
193
|
+
*/
|
|
194
|
+
export function dueSubscriptionGrantSlices(periodStart, periodEnd, now) {
|
|
195
|
+
return subscriptionGrantSlices(periodStart, periodEnd).filter((slice) => slice.start.getTime() <= now.getTime());
|
|
196
|
+
}
|
|
197
|
+
/** The slice `now` falls in, or null outside the period. */
|
|
198
|
+
export function currentSubscriptionGrantSlice(periodStart, periodEnd, now) {
|
|
199
|
+
return (subscriptionGrantSlices(periodStart, periodEnd).find((slice) => slice.start.getTime() <= now.getTime() && now.getTime() < slice.end.getTime()) ?? null);
|
|
200
|
+
}
|
|
@@ -60,8 +60,8 @@ export declare const CREDIT_TOPUP_DEFAULT_CREDITS = 2000;
|
|
|
60
60
|
export declare function isValidCreditTopupCredits(credits: number): boolean;
|
|
61
61
|
/**
|
|
62
62
|
* A shortfall expressed as an amount `oxygen billing topup` will actually SELL:
|
|
63
|
-
* rounded up onto
|
|
64
|
-
* [
|
|
63
|
+
* rounded up onto CREDIT_TOPUP_STEP_CREDITS (100) and clamped into the purchasable
|
|
64
|
+
* [CREDIT_TOPUP_MIN_CREDITS .. CREDIT_TOPUP_MAX_CREDITS] band (800 .. 100,000). A next_action (or an alert email) naming a number the
|
|
65
65
|
* top-up route refuses with `invalid_topup_amount` is not a next action, it is a
|
|
66
66
|
* second dead end during the incident it exists to end.
|
|
67
67
|
*
|
|
@@ -76,15 +76,41 @@ export declare function isValidCreditTopupCredits(credits: number): boolean;
|
|
|
76
76
|
*/
|
|
77
77
|
export declare function creditTopupAmountForShortfall(credits: number): number;
|
|
78
78
|
export declare function creditTopupUsdCents(credits: number): number | null;
|
|
79
|
+
/**
|
|
80
|
+
* Price of a custom top-up at an explicit rate. A checkout session records the
|
|
81
|
+
* rate it was quoted at (`CREDIT_TOPUP_RATE_METADATA_KEY`), and fulfillment
|
|
82
|
+
* re-prices it at THAT rate, never the rate in force when Stripe confirms the
|
|
83
|
+
* payment: a session opened before a rate change and paid after it must still
|
|
84
|
+
* be credited.
|
|
85
|
+
*/
|
|
86
|
+
export declare function creditTopupUsdCentsAtRate(credits: number, usdCentsPer100: number): number | null;
|
|
87
|
+
/** Checkout-session metadata key carrying the rate a top-up was quoted at. */
|
|
88
|
+
export declare const CREDIT_TOPUP_RATE_METADATA_KEY = "usd_cents_per_100";
|
|
89
|
+
/**
|
|
90
|
+
* Every top-up rate (US cents per 100 credits) a checkout has ever been minted
|
|
91
|
+
* at. Append when the rate changes; never remove one, because an unpaid session
|
|
92
|
+
* quoted at it can still be paid. The first entry is also the rate of sessions
|
|
93
|
+
* created before the rate was written into their metadata.
|
|
94
|
+
*/
|
|
95
|
+
export declare const CREDIT_TOPUP_SOLD_USD_CENTS_PER_100: readonly [125];
|
|
96
|
+
export declare const CREDIT_TOPUP_LEGACY_USD_CENTS_PER_100: number;
|
|
97
|
+
/**
|
|
98
|
+
* Whether fulfillment may price a session at `usdCentsPer100`: a rate OXYGEN has
|
|
99
|
+
* sold at, or the rate in force now. Anything else is metadata no OXYGEN server
|
|
100
|
+
* wrote, and it is refused rather than credited at a cheaper rate.
|
|
101
|
+
*/
|
|
102
|
+
export declare function isAcceptedCreditTopupRate(usdCentsPer100: number, currentUsdCentsPer100?: number): boolean;
|
|
79
103
|
export declare const AUTOMATION_ACTION_CREDITS = 0.001;
|
|
80
104
|
/** Kinds of resource that carry a fixed monthly credit commitment. */
|
|
81
|
-
export declare const CREDIT_COMMITMENT_KINDS: readonly ["sending_mailbox", "managed_mailbox", "mailbox_warmup", "deliverability_unit", "linkedin_account", "whatsapp_account"];
|
|
105
|
+
export declare const CREDIT_COMMITMENT_KINDS: readonly ["sending_mailbox", "managed_mailbox", "mailbox_warmup", "deliverability_unit", "linkedin_account", "whatsapp_account", "x_account", "phone_number"];
|
|
82
106
|
export type CreditCommitmentKind = (typeof CREDIT_COMMITMENT_KINDS)[number];
|
|
83
107
|
/**
|
|
84
108
|
* Sequencer platform fee per CONNECTED SENDING MAILBOX per month, in credits
|
|
85
109
|
* ($1.00). Charged on EVERY mailbox wired to the sequencer — BYOK/self-connected
|
|
86
110
|
* Gmail and Microsoft inboxes included — and it STACKS on Oxygen-sold mailboxes,
|
|
87
|
-
* which additionally pay their own mailbox/warmup/placement lines.
|
|
111
|
+
* which additionally pay their own mailbox/warmup/placement lines. Read from the
|
|
112
|
+
* pricing seed (`commitment.sending_mailbox`) through the generated snapshot, so
|
|
113
|
+
* there is one definition of the price.
|
|
88
114
|
*/
|
|
89
115
|
export declare const SENDING_MAILBOX_MONTHLY_CREDITS = 100;
|
|
90
116
|
/**
|
|
@@ -93,12 +119,12 @@ export declare const SENDING_MAILBOX_MONTHLY_CREDITS = 100;
|
|
|
93
119
|
*/
|
|
94
120
|
export declare const MANAGED_MAILBOX_MONTHLY_CREDITS = 300;
|
|
95
121
|
/**
|
|
96
|
-
* Grace window after a commitment goes past_due before the
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
122
|
+
* Grace window after a commitment goes past_due before the account disconnects.
|
|
123
|
+
* Repricing 2026-09 decision 2.8: sending from the account pauses at once, and
|
|
124
|
+
* after 7 days without funds the account disconnects (replacing the earlier 14
|
|
125
|
+
* days with no auto-cancel). The pause and the disconnect sweep are slice S23.
|
|
100
126
|
*/
|
|
101
|
-
export declare const COMMITMENT_PAST_DUE_GRACE_DAYS =
|
|
127
|
+
export declare const COMMITMENT_PAST_DUE_GRACE_DAYS = 7;
|
|
102
128
|
/**
|
|
103
129
|
* Reconnect window in which an ENDED commitment resumes instead of starting a new
|
|
104
130
|
* one. The single most important anti-double-charge rule: a Unipile re-auth, a
|
|
@@ -361,6 +387,13 @@ export declare function getCurrentBillingCycleKey(date?: Date): string;
|
|
|
361
387
|
* billing-anchor shift without admitting a quarterly or annual period.
|
|
362
388
|
*/
|
|
363
389
|
export declare const CREDIT_CYCLE_MAX_PERIOD_DAYS = 35;
|
|
390
|
+
/**
|
|
391
|
+
* Annual billing (Repricing 2026-09, decision 1.2): a year costs ten months of
|
|
392
|
+
* the monthly price, and the credits are still granted monthly.
|
|
393
|
+
*/
|
|
394
|
+
export declare const ANNUAL_BILLING_MONTHS_CHARGED = 10;
|
|
395
|
+
/** The yearly price of a rung, in cents: ten times its monthly price. */
|
|
396
|
+
export declare function annualPlanPriceCents(monthlyPriceCents: number): number;
|
|
364
397
|
export type CreditCycleSource = "subscription_period" | "calendar_month";
|
|
365
398
|
export type CreditCycleWindow = {
|
|
366
399
|
start: Date;
|
|
@@ -389,6 +422,21 @@ export declare function isStripeManagedSubscription(subscription: {
|
|
|
389
422
|
stripeSubscriptionId?: string | null;
|
|
390
423
|
metadata?: Record<string, unknown> | null;
|
|
391
424
|
}): boolean;
|
|
425
|
+
/**
|
|
426
|
+
* Whether a Stripe subscription is scheduled to end. A classic-billing-mode
|
|
427
|
+
* period-end cancel sets `cancel_at_period_end`; a Billing Portal cancel on a
|
|
428
|
+
* flexible-billing-mode subscription sets only `cancel_at` (to the period end)
|
|
429
|
+
* and leaves `cancel_at_period_end` false (Stripe billing-mode comparison docs;
|
|
430
|
+
* reproduced in test mode 2026-09-26). Reading the boolean alone showed a
|
|
431
|
+
* Portal-cancelled plan as renewing. `cancel_at` on a subscription that has
|
|
432
|
+
* already ended records when it ended, not a pending cancellation, so it only
|
|
433
|
+
* counts while the subscription is still live.
|
|
434
|
+
*/
|
|
435
|
+
export declare function stripeSubscriptionCancellationScheduled(subscription: {
|
|
436
|
+
status: string;
|
|
437
|
+
cancel_at_period_end?: boolean | null;
|
|
438
|
+
cancel_at?: number | null;
|
|
439
|
+
}): boolean;
|
|
392
440
|
/**
|
|
393
441
|
* The window the credit-allowance bar measures: "this month's credits", resolved
|
|
394
442
|
* to the boundary at which the plan's grant actually renews.
|
|
@@ -412,6 +460,12 @@ export declare function resolveCreditCycleWindow(input: {
|
|
|
412
460
|
currentPeriodEnd: Date | null;
|
|
413
461
|
/** isOffStripeSubscription(subscription.metadata) — resolved by the caller. */
|
|
414
462
|
offStripe: boolean;
|
|
463
|
+
/**
|
|
464
|
+
* planBillingIntervalForPriceId(subscription.stripePriceId). A yearly
|
|
465
|
+
* period grants its credits in monthly slices, so the cycle the bar measures
|
|
466
|
+
* is the current slice, anchored to the period start (P-8).
|
|
467
|
+
*/
|
|
468
|
+
billingInterval?: "month" | "year" | null;
|
|
415
469
|
} | null;
|
|
416
470
|
now?: Date;
|
|
417
471
|
}): CreditCycleWindow;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { AUTOMATION_ACTION_CREDITS as SNAPSHOT_AUTOMATION_ACTION_CREDITS, CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, PRICING_PLAN_SNAPSHOT, TOPUP_USD_CENTS_PER_100_CREDITS as SNAPSHOT_TOPUP_USD_CENTS_PER_100_CREDITS, } from "./pricing-snapshot.generated.js";
|
|
1
|
+
import { AUTOMATION_ACTION_CREDITS as SNAPSHOT_AUTOMATION_ACTION_CREDITS, CREDITS_PER_USD as SNAPSHOT_CREDITS_PER_USD, PRICING_PLAN_SNAPSHOT, SENDING_MAILBOX_CREDITS_PER_MONTH as SNAPSHOT_SENDING_MAILBOX_CREDITS_PER_MONTH, TOPUP_USD_CENTS_PER_100_CREDITS as SNAPSHOT_TOPUP_USD_CENTS_PER_100_CREDITS, } from "./pricing-snapshot.generated.js";
|
|
2
|
+
import { currentSubscriptionGrantSlice } from "./billing-anchors.js";
|
|
2
3
|
import { freeTierEntitlementEnabled } from "./plan-capabilities.js";
|
|
3
4
|
import { asRecordOrNull } from "./type-guards.js";
|
|
4
5
|
export const WEEKLY_USAGE_WINDOW_DAYS = 7;
|
|
@@ -65,8 +66,8 @@ export function isValidCreditTopupCredits(credits) {
|
|
|
65
66
|
}
|
|
66
67
|
/**
|
|
67
68
|
* A shortfall expressed as an amount `oxygen billing topup` will actually SELL:
|
|
68
|
-
* rounded up onto
|
|
69
|
-
* [
|
|
69
|
+
* rounded up onto CREDIT_TOPUP_STEP_CREDITS (100) and clamped into the purchasable
|
|
70
|
+
* [CREDIT_TOPUP_MIN_CREDITS .. CREDIT_TOPUP_MAX_CREDITS] band (800 .. 100,000). A next_action (or an alert email) naming a number the
|
|
70
71
|
* top-up route refuses with `invalid_topup_amount` is not a next action, it is a
|
|
71
72
|
* second dead end during the incident it exists to end.
|
|
72
73
|
*
|
|
@@ -84,9 +85,42 @@ export function creditTopupAmountForShortfall(credits) {
|
|
|
84
85
|
return Math.min(CREDIT_TOPUP_MAX_CREDITS, Math.max(CREDIT_TOPUP_MIN_CREDITS, stepped));
|
|
85
86
|
}
|
|
86
87
|
export function creditTopupUsdCents(credits) {
|
|
88
|
+
return creditTopupUsdCentsAtRate(credits, TOPUP_USD_CENTS_PER_100_CREDITS);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Price of a custom top-up at an explicit rate. A checkout session records the
|
|
92
|
+
* rate it was quoted at (`CREDIT_TOPUP_RATE_METADATA_KEY`), and fulfillment
|
|
93
|
+
* re-prices it at THAT rate, never the rate in force when Stripe confirms the
|
|
94
|
+
* payment: a session opened before a rate change and paid after it must still
|
|
95
|
+
* be credited.
|
|
96
|
+
*/
|
|
97
|
+
export function creditTopupUsdCentsAtRate(credits, usdCentsPer100) {
|
|
87
98
|
if (!isValidCreditTopupCredits(credits))
|
|
88
99
|
return null;
|
|
89
|
-
|
|
100
|
+
if (!Number.isInteger(usdCentsPer100) || usdCentsPer100 <= 0)
|
|
101
|
+
return null;
|
|
102
|
+
return (credits / 100) * usdCentsPer100;
|
|
103
|
+
}
|
|
104
|
+
/** Checkout-session metadata key carrying the rate a top-up was quoted at. */
|
|
105
|
+
export const CREDIT_TOPUP_RATE_METADATA_KEY = "usd_cents_per_100";
|
|
106
|
+
/**
|
|
107
|
+
* Every top-up rate (US cents per 100 credits) a checkout has ever been minted
|
|
108
|
+
* at. Append when the rate changes; never remove one, because an unpaid session
|
|
109
|
+
* quoted at it can still be paid. The first entry is also the rate of sessions
|
|
110
|
+
* created before the rate was written into their metadata.
|
|
111
|
+
*/
|
|
112
|
+
export const CREDIT_TOPUP_SOLD_USD_CENTS_PER_100 = [125];
|
|
113
|
+
export const CREDIT_TOPUP_LEGACY_USD_CENTS_PER_100 = CREDIT_TOPUP_SOLD_USD_CENTS_PER_100[0];
|
|
114
|
+
/**
|
|
115
|
+
* Whether fulfillment may price a session at `usdCentsPer100`: a rate OXYGEN has
|
|
116
|
+
* sold at, or the rate in force now. Anything else is metadata no OXYGEN server
|
|
117
|
+
* wrote, and it is refused rather than credited at a cheaper rate.
|
|
118
|
+
*/
|
|
119
|
+
export function isAcceptedCreditTopupRate(usdCentsPer100, currentUsdCentsPer100 = TOPUP_USD_CENTS_PER_100_CREDITS) {
|
|
120
|
+
if (!Number.isInteger(usdCentsPer100) || usdCentsPer100 <= 0)
|
|
121
|
+
return false;
|
|
122
|
+
return usdCentsPer100 === currentUsdCentsPer100
|
|
123
|
+
|| CREDIT_TOPUP_SOLD_USD_CENTS_PER_100.includes(usdCentsPer100);
|
|
90
124
|
}
|
|
91
125
|
// Workflow/automation actions (steps, tool calls, row writes, retries) draw
|
|
92
126
|
// from the same credit pool at a flat capacity-guard rate — the old separate
|
|
@@ -122,26 +156,33 @@ export const CREDIT_COMMITMENT_KINDS = [
|
|
|
122
156
|
"deliverability_unit",
|
|
123
157
|
"linkedin_account",
|
|
124
158
|
"whatsapp_account",
|
|
159
|
+
// Repricing 2026-09 (S20, decisions 2.2 and 2.4). Priced in the pricing seed
|
|
160
|
+
// (seat.x_account, seat.phone_number) and reserve nothing until the
|
|
161
|
+
// repricing's effective-date switch.
|
|
162
|
+
"x_account",
|
|
163
|
+
"phone_number",
|
|
125
164
|
];
|
|
126
165
|
/**
|
|
127
166
|
* Sequencer platform fee per CONNECTED SENDING MAILBOX per month, in credits
|
|
128
167
|
* ($1.00). Charged on EVERY mailbox wired to the sequencer — BYOK/self-connected
|
|
129
168
|
* Gmail and Microsoft inboxes included — and it STACKS on Oxygen-sold mailboxes,
|
|
130
|
-
* which additionally pay their own mailbox/warmup/placement lines.
|
|
169
|
+
* which additionally pay their own mailbox/warmup/placement lines. Read from the
|
|
170
|
+
* pricing seed (`commitment.sending_mailbox`) through the generated snapshot, so
|
|
171
|
+
* there is one definition of the price.
|
|
131
172
|
*/
|
|
132
|
-
export const SENDING_MAILBOX_MONTHLY_CREDITS =
|
|
173
|
+
export const SENDING_MAILBOX_MONTHLY_CREDITS = SNAPSHOT_SENDING_MAILBOX_CREDITS_PER_MONTH;
|
|
133
174
|
/**
|
|
134
175
|
* Oxygen-sold managed mailbox per month, in credits ($3.00). Flat, superseding
|
|
135
176
|
* the dynamic vendor-COGS x 1.25 quote for the recurring mailbox line.
|
|
136
177
|
*/
|
|
137
178
|
export const MANAGED_MAILBOX_MONTHLY_CREDITS = 300;
|
|
138
179
|
/**
|
|
139
|
-
* Grace window after a commitment goes past_due before the
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
180
|
+
* Grace window after a commitment goes past_due before the account disconnects.
|
|
181
|
+
* Repricing 2026-09 decision 2.8: sending from the account pauses at once, and
|
|
182
|
+
* after 7 days without funds the account disconnects (replacing the earlier 14
|
|
183
|
+
* days with no auto-cancel). The pause and the disconnect sweep are slice S23.
|
|
143
184
|
*/
|
|
144
|
-
export const COMMITMENT_PAST_DUE_GRACE_DAYS =
|
|
185
|
+
export const COMMITMENT_PAST_DUE_GRACE_DAYS = 7;
|
|
145
186
|
/**
|
|
146
187
|
* Reconnect window in which an ENDED commitment resumes instead of starting a new
|
|
147
188
|
* one. The single most important anti-double-charge rule: a Unipile re-auth, a
|
|
@@ -187,11 +228,13 @@ export function ratifiedCommitmentCredits(kind) {
|
|
|
187
228
|
case "mailbox_warmup":
|
|
188
229
|
case "deliverability_unit":
|
|
189
230
|
return null;
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
231
|
+
// Connected-account reservations are priced in the pricing seed
|
|
232
|
+
// (seat.*_account, seat.phone_number) and resolved, through the 2026-09
|
|
233
|
+
// repricing switch, by commitmentPrice in @oxygen/integrations.
|
|
193
234
|
case "linkedin_account":
|
|
194
235
|
case "whatsapp_account":
|
|
236
|
+
case "x_account":
|
|
237
|
+
case "phone_number":
|
|
195
238
|
return null;
|
|
196
239
|
}
|
|
197
240
|
}
|
|
@@ -751,6 +794,15 @@ export function getCurrentBillingCycleKey(date = new Date()) {
|
|
|
751
794
|
* billing-anchor shift without admitting a quarterly or annual period.
|
|
752
795
|
*/
|
|
753
796
|
export const CREDIT_CYCLE_MAX_PERIOD_DAYS = 35;
|
|
797
|
+
/**
|
|
798
|
+
* Annual billing (Repricing 2026-09, decision 1.2): a year costs ten months of
|
|
799
|
+
* the monthly price, and the credits are still granted monthly.
|
|
800
|
+
*/
|
|
801
|
+
export const ANNUAL_BILLING_MONTHS_CHARGED = 10;
|
|
802
|
+
/** The yearly price of a rung, in cents: ten times its monthly price. */
|
|
803
|
+
export function annualPlanPriceCents(monthlyPriceCents) {
|
|
804
|
+
return monthlyPriceCents * ANNUAL_BILLING_MONTHS_CHARGED;
|
|
805
|
+
}
|
|
754
806
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
755
807
|
function startOfUtcMonth(date) {
|
|
756
808
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
|
|
@@ -800,6 +852,22 @@ export function isStripeManagedSubscription(subscription) {
|
|
|
800
852
|
&& metadata.off_stripe !== "true"
|
|
801
853
|
&& metadata.synthetic !== true);
|
|
802
854
|
}
|
|
855
|
+
/**
|
|
856
|
+
* Whether a Stripe subscription is scheduled to end. A classic-billing-mode
|
|
857
|
+
* period-end cancel sets `cancel_at_period_end`; a Billing Portal cancel on a
|
|
858
|
+
* flexible-billing-mode subscription sets only `cancel_at` (to the period end)
|
|
859
|
+
* and leaves `cancel_at_period_end` false (Stripe billing-mode comparison docs;
|
|
860
|
+
* reproduced in test mode 2026-09-26). Reading the boolean alone showed a
|
|
861
|
+
* Portal-cancelled plan as renewing. `cancel_at` on a subscription that has
|
|
862
|
+
* already ended records when it ended, not a pending cancellation, so it only
|
|
863
|
+
* counts while the subscription is still live.
|
|
864
|
+
*/
|
|
865
|
+
export function stripeSubscriptionCancellationScheduled(subscription) {
|
|
866
|
+
if (subscription.cancel_at_period_end === true)
|
|
867
|
+
return true;
|
|
868
|
+
const ended = subscription.status === "canceled" || subscription.status === "incomplete_expired";
|
|
869
|
+
return !ended && subscription.cancel_at != null;
|
|
870
|
+
}
|
|
803
871
|
/**
|
|
804
872
|
* The window the credit-allowance bar measures: "this month's credits", resolved
|
|
805
873
|
* to the boundary at which the plan's grant actually renews.
|
|
@@ -822,6 +890,20 @@ export function resolveCreditCycleWindow(input) {
|
|
|
822
890
|
const subscription = input.subscription;
|
|
823
891
|
const start = subscription?.currentPeriodStart ?? null;
|
|
824
892
|
const end = subscription?.currentPeriodEnd ?? null;
|
|
893
|
+
if (subscription?.billingInterval === "year"
|
|
894
|
+
&& !subscription.offStripe
|
|
895
|
+
&& start != null
|
|
896
|
+
&& end != null) {
|
|
897
|
+
const slice = currentSubscriptionGrantSlice(start, end, now);
|
|
898
|
+
if (slice) {
|
|
899
|
+
return {
|
|
900
|
+
start: slice.start,
|
|
901
|
+
end: slice.end,
|
|
902
|
+
source: "subscription_period",
|
|
903
|
+
label: `${formatUtcDay(slice.start)} – ${formatUtcDay(slice.end)}`,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
}
|
|
825
907
|
const usesPeriod = subscription != null
|
|
826
908
|
&& !subscription.offStripe
|
|
827
909
|
&& start != null
|
|
@@ -505,7 +505,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
505
505
|
gatewayCommands: ["linkedin network setup", "linkedin invitations setup", "senders list", "connections import", "inbox list"],
|
|
506
506
|
skills: ["oxygen-linkedin-marketing", "oxygen-sequencer", "oxygen-unibox"],
|
|
507
507
|
endpointSections: ["linkedin"],
|
|
508
|
-
intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "my network", "whole network", "linkedin network", "connections and followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox"],
|
|
508
|
+
intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "my network", "whole network", "linkedin network", "connections and followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox", "working hours", "sending hours", "linkedin limits"],
|
|
509
509
|
},
|
|
510
510
|
{
|
|
511
511
|
id: "connected-whatsapp",
|
|
@@ -974,6 +974,12 @@ function recommendationsFor(card, query) {
|
|
|
974
974
|
exactCommand: LOCAL_BUSINESS_SCRAPE_COMMAND,
|
|
975
975
|
};
|
|
976
976
|
}
|
|
977
|
+
if (card.id === "connected-linkedin" && isLinkedInAccountLimitsIntent(query)) {
|
|
978
|
+
return {
|
|
979
|
+
tools: ["oxygen_senders_limits_get", "oxygen_senders_limits_set", "oxygen_senders_list"],
|
|
980
|
+
commands: ["senders limits get", "senders limits set", "senders list"],
|
|
981
|
+
};
|
|
982
|
+
}
|
|
977
983
|
if (card.id === "connected-linkedin" && isMutualLinkedInConnectionsIntent(query)) {
|
|
978
984
|
return {
|
|
979
985
|
tools: ["oxygen_tools_get", "oxygen_tools_run_live", "oxygen_senders_list"],
|
|
@@ -1322,6 +1328,10 @@ function isSenderProfileIntent(query) {
|
|
|
1322
1328
|
|| (/\bprofiles?\b.{0,64}\b(?:linkedin|whatsapp|mailboxes?|inboxes?|email)\b/.test(query)
|
|
1323
1329
|
&& /\b(?:sender|sending|unif(?:y|ied))\b/.test(query));
|
|
1324
1330
|
}
|
|
1331
|
+
/** The account's own working hours or daily caps (`senders limits`), not its network. */
|
|
1332
|
+
function isLinkedInAccountLimitsIntent(query) {
|
|
1333
|
+
return /\b(working hours|sending hours|business hours|office hours|weekdays?|time ?zone|linkedin limits|daily limits?)\b/.test(query);
|
|
1334
|
+
}
|
|
1325
1335
|
function isMutualLinkedInConnectionsIntent(query) {
|
|
1326
1336
|
const linkedInContext = /\blinkedin\b|\bprofiles?\b/.test(query);
|
|
1327
1337
|
const mutualContext = /\b(mutual|shared|in common)\b.{0,48}\b(connections?|relations?)\b/.test(query)
|