@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export declare const REPRICING_2026_09_EFFECTIVE_AT_ENV_VAR = "OXYGEN_REPRICING_2026_09_EFFECTIVE_AT";
|
|
2
|
+
/** Stable machine name of this price change, as reported on customer surfaces. */
|
|
3
|
+
export declare const REPRICING_2026_09_NAME = "repricing_2026_09";
|
|
4
|
+
type RepricingEnv = {
|
|
5
|
+
[key: string]: string | undefined;
|
|
6
|
+
};
|
|
7
|
+
export type RepricingOptions = {
|
|
8
|
+
env?: RepricingEnv;
|
|
9
|
+
/** The instant to resolve at. Defaults to the current time. */
|
|
10
|
+
now?: Date | number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* `unset` and `invalid` both keep every old value. `scheduled` names a future
|
|
14
|
+
* instant (old values still in force); `in_force` means the new values apply.
|
|
15
|
+
*/
|
|
16
|
+
export type RepricingSwitchState = {
|
|
17
|
+
status: "unset";
|
|
18
|
+
effectiveAt: null;
|
|
19
|
+
} | {
|
|
20
|
+
status: "invalid";
|
|
21
|
+
effectiveAt: null;
|
|
22
|
+
raw: string;
|
|
23
|
+
} | {
|
|
24
|
+
status: "scheduled";
|
|
25
|
+
effectiveAt: Date;
|
|
26
|
+
} | {
|
|
27
|
+
status: "in_force";
|
|
28
|
+
effectiveAt: Date;
|
|
29
|
+
};
|
|
30
|
+
/** Parse the switch's raw value. `null` for unset, `undefined` for unparseable. */
|
|
31
|
+
export declare function parseRepricingEffectiveAt(raw: string | undefined): Date | null | undefined;
|
|
32
|
+
/** Where the switch stands at `now`. */
|
|
33
|
+
export declare function readRepricingSwitch(options?: RepricingOptions): RepricingSwitchState;
|
|
34
|
+
/** True once the switch names an instant that has passed. */
|
|
35
|
+
export declare function isRepricingInForce(options?: RepricingOptions): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* A price or limit value. `null` means "no bound", and only a limit's `before`
|
|
38
|
+
* may hold it: a limit that did not exist and now does is a tightening that
|
|
39
|
+
* waits for the switch (the email per-mailbox maximum, S52).
|
|
40
|
+
*/
|
|
41
|
+
export type RepricedScalar = number | boolean | string | null;
|
|
42
|
+
type Widen<T> = T extends null ? null : T extends number ? number : T extends boolean ? boolean : T extends string ? string : never;
|
|
43
|
+
/** The value in force: `before` until the switch's instant, `after` from it on. */
|
|
44
|
+
export declare function repricedValue<T extends RepricedScalar>(value: {
|
|
45
|
+
readonly before: T;
|
|
46
|
+
readonly after: T;
|
|
47
|
+
}, options?: RepricingOptions): Widen<T>;
|
|
48
|
+
/**
|
|
49
|
+
* One price that rises or one limit that tightens at the switch's instant.
|
|
50
|
+
* `key` is the stable machine name surfaces report (`topup.usd_cents_per_100_credits`),
|
|
51
|
+
* `slice` and `decision` point back at the specification and decision record.
|
|
52
|
+
*/
|
|
53
|
+
export type RepricedValue<T extends RepricedScalar = RepricedScalar> = {
|
|
54
|
+
readonly key: string;
|
|
55
|
+
readonly kind: "price" | "limit";
|
|
56
|
+
readonly slice: string;
|
|
57
|
+
readonly decision: string;
|
|
58
|
+
readonly unit: string;
|
|
59
|
+
readonly description: string;
|
|
60
|
+
readonly before: T;
|
|
61
|
+
readonly after: T;
|
|
62
|
+
};
|
|
63
|
+
export declare function defineRepricedValue<T extends RepricedScalar>(definition: RepricedValue<T>): RepricedValue<T>;
|
|
64
|
+
/** The hard per-mailbox maximum: none before the instant, 50 a day from it. */
|
|
65
|
+
export declare const EMAIL_MAILBOX_DAILY_CAP_MAXIMUM: RepricedValue<number | null>;
|
|
66
|
+
/**
|
|
67
|
+
* A mailbox still on the old default of 40 a day, which nobody set by hand, sends
|
|
68
|
+
* at the new default of 25 (P-58, proposed). The column was `DEFAULT 40`, so a
|
|
69
|
+
* deliberate 40 set before `daily_cap_source` existed cannot be told apart;
|
|
70
|
+
* setting the cap again records it as the customer's own, and it is then kept.
|
|
71
|
+
*/
|
|
72
|
+
export declare const EMAIL_MAILBOX_OLD_DEFAULT_DAILY_CAP: RepricedValue<25 | 40>;
|
|
73
|
+
/**
|
|
74
|
+
* S20: the monthly credit reservation of every connected account (decisions
|
|
75
|
+
* 2.1–2.4). Each key is the pricing-seed charge key, so the seed row carries the
|
|
76
|
+
* new price and `repricedChargeCredits` holds the old one until the instant.
|
|
77
|
+
*
|
|
78
|
+
* A `before` of 0 means the kind had no credit reservation at all: X accounts
|
|
79
|
+
* were never priced, and a phone number was paid through its $10 Stripe seat
|
|
80
|
+
* rather than in credits. Such a kind reserves nothing until the instant.
|
|
81
|
+
*/
|
|
82
|
+
export declare const S20_RESERVATION_PRICE_RISES: readonly RepricedValue<number>[];
|
|
83
|
+
/**
|
|
84
|
+
* Every value the 2026-09 switch moves. Each dark slice appends its entries
|
|
85
|
+
* here, so `oxygen limits show` can list them once a date is set and one test
|
|
86
|
+
* proves each resolves to its old value before the instant and its new value
|
|
87
|
+
* after it.
|
|
88
|
+
*/
|
|
89
|
+
export declare const REPRICING_2026_09_SCHEDULE: readonly RepricedValue[];
|
|
90
|
+
/** The schedule entry with this key, or undefined. */
|
|
91
|
+
export declare function findRepricedValue(key: string, schedule?: readonly RepricedValue[]): RepricedValue | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* The credits a recurring charge costs right now, given the price stored in the
|
|
94
|
+
* pricing book (database row or seed).
|
|
95
|
+
*
|
|
96
|
+
* The seed and the S20 migration carry the NEW price, so a charge key with a
|
|
97
|
+
* scheduled rise must not reach a customer before the instant. Until then a
|
|
98
|
+
* stored price equal to the scheduled new price resolves to the old one. Any
|
|
99
|
+
* other stored price is a staff override made at /admin/costs and stands, as it
|
|
100
|
+
* always has. From the instant on the stored price stands. A key with no
|
|
101
|
+
* scheduled rise passes through unchanged. A result of 0 means "not priced yet":
|
|
102
|
+
* callers that reserve credits treat it as unpriced, never as free.
|
|
103
|
+
*/
|
|
104
|
+
export declare function repricedChargeCredits(chargeKey: string, storedCredits: number, options?: RepricingOptions): number;
|
|
105
|
+
/** Human-readable problems with a schedule; empty when it is sound. */
|
|
106
|
+
export declare function findRepricingScheduleViolations(schedule?: readonly RepricedValue[]): string[];
|
|
107
|
+
export type RepricingScheduleChange = {
|
|
108
|
+
key: string;
|
|
109
|
+
kind: "price" | "limit";
|
|
110
|
+
unit: string;
|
|
111
|
+
description: string;
|
|
112
|
+
value_in_force: RepricedScalar;
|
|
113
|
+
value_before: RepricedScalar;
|
|
114
|
+
value_after: RepricedScalar;
|
|
115
|
+
};
|
|
116
|
+
export type RepricingScheduleDescription = {
|
|
117
|
+
name: typeof REPRICING_2026_09_NAME;
|
|
118
|
+
effective_at: string;
|
|
119
|
+
status: "scheduled" | "in_force";
|
|
120
|
+
changes: RepricingScheduleChange[];
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* What customer surfaces report about the scheduled change. `null` while the
|
|
124
|
+
* switch is unset or unparseable: nothing is announced until an operator sets a
|
|
125
|
+
* valid instant, so an inert switch changes no customer-visible output.
|
|
126
|
+
*/
|
|
127
|
+
export declare function describeRepricingSchedule(options?: RepricingOptions & {
|
|
128
|
+
schedule?: readonly RepricedValue[];
|
|
129
|
+
}): RepricingScheduleDescription | null;
|
|
130
|
+
export {};
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// The 2026-09 repricing effective-date switch, and the one place that reads
|
|
2
|
+
// `OXYGEN_REPRICING_2026_09_EFFECTIVE_AT`.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. A production release carries everything on `dev`, so no price
|
|
5
|
+
// rise can be held back by "not merged yet". The 2026-09 repricing (oxygen-knowledge
|
|
6
|
+
// `wiki/ops/Repricing 2026-09 Specification.md` §6.1, slice S07) ships every rise
|
|
7
|
+
// and every tightened limit dark, then moves them all at one instant, 30 days
|
|
8
|
+
// after customers are told. Each such value is declared once in
|
|
9
|
+
// `REPRICING_2026_09_SCHEDULE` below with its old and new value, and resolves to
|
|
10
|
+
// the old value before the instant and to the new value from it on.
|
|
11
|
+
//
|
|
12
|
+
// WHAT GOES THROUGH HERE, AND WHAT NEVER DOES.
|
|
13
|
+
//
|
|
14
|
+
// - A price that rises or a limit that tightens: a snapshot constant (top-up
|
|
15
|
+
// rate, automation step, BYOK fee, ...) or a plan limit. Its slice adds one
|
|
16
|
+
// `defineRepricedValue` entry and reads it with `repricedValue`.
|
|
17
|
+
// - Never a decrease or a limit increase. Those ship live on release (§6.1), so
|
|
18
|
+
// holding one back would only delay a customer benefit; the schedule check
|
|
19
|
+
// below refuses a price entry whose new value is not higher.
|
|
20
|
+
// - Database prices (`pricing_recurring_charges`, `pricing_scalars`) move with
|
|
21
|
+
// the T + 30 migration batch (S91b). A reader that must hold a database value
|
|
22
|
+
// until the instant passes the stored row as `after` and the pre-change value
|
|
23
|
+
// as `before`, exactly like a snapshot constant.
|
|
24
|
+
//
|
|
25
|
+
// UNSET MEANS NEVER, deliberately and permanently: a variable nobody set must
|
|
26
|
+
// never raise a customer's price. The value is an ISO 8601 instant WITH an
|
|
27
|
+
// explicit offset (`2026-11-01T00:00:00Z`); a bare date or local time would move
|
|
28
|
+
// prices at a different moment on every host. An unparseable value is treated
|
|
29
|
+
// exactly like an unset one and old values stay in force, because the
|
|
30
|
+
// customer-safe failure of a mistyped date is "prices did not rise yet", never
|
|
31
|
+
// "prices rose early". The state says `invalid` so a server caller can log it.
|
|
32
|
+
//
|
|
33
|
+
// ONE VALUE FOR EVERY PROCESS. The web app, the legacy worker and every BullMQ
|
|
34
|
+
// role price the same work, so the variable lives in Doppler `oxygen-shared`
|
|
35
|
+
// (inherited by web and worker) and in the runtime roles' common vault names
|
|
36
|
+
// (`ops/selfhost/worker-queue/apply-runtime.py`). Setting it is a production
|
|
37
|
+
// configuration change that needs explicit human approval (S95); rolling back
|
|
38
|
+
// before the instant is unsetting it.
|
|
39
|
+
//
|
|
40
|
+
// Read per call rather than memoized at module load, like the cutover freeze:
|
|
41
|
+
// tests drive both states, and an operator's change must not need a restart to
|
|
42
|
+
// be seen consistently. Parsing one short string per call is negligible.
|
|
43
|
+
//
|
|
44
|
+
// No imports on purpose: `@oxygen/shared` ships inside the published CLI, and
|
|
45
|
+
// "use client" components may import the schedule. A client component cannot
|
|
46
|
+
// read the server's environment, though, so a page that shows a price in force
|
|
47
|
+
// must resolve it on the server and pass it down.
|
|
48
|
+
export const REPRICING_2026_09_EFFECTIVE_AT_ENV_VAR = "OXYGEN_REPRICING_2026_09_EFFECTIVE_AT";
|
|
49
|
+
/** Stable machine name of this price change, as reported on customer surfaces. */
|
|
50
|
+
export const REPRICING_2026_09_NAME = "repricing_2026_09";
|
|
51
|
+
// Date and time with an explicit `Z` or `±hh:mm` offset. Seconds and up to
|
|
52
|
+
// millisecond fractions are optional.
|
|
53
|
+
const ISO_INSTANT_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
54
|
+
/** Parse the switch's raw value. `null` for unset, `undefined` for unparseable. */
|
|
55
|
+
export function parseRepricingEffectiveAt(raw) {
|
|
56
|
+
const value = raw?.trim();
|
|
57
|
+
if (!value)
|
|
58
|
+
return null;
|
|
59
|
+
if (!ISO_INSTANT_WITH_OFFSET.test(value))
|
|
60
|
+
return undefined;
|
|
61
|
+
const ms = Date.parse(value);
|
|
62
|
+
if (!Number.isFinite(ms))
|
|
63
|
+
return undefined;
|
|
64
|
+
const parsed = new Date(ms);
|
|
65
|
+
// Date.parse rolls impossible calendar dates over (2026-02-30 → 2026-03-02);
|
|
66
|
+
// an instant that does not round-trip its own date part was mistyped.
|
|
67
|
+
const offsetMatch = /([+-])(\d{2}):(\d{2})$/.exec(value);
|
|
68
|
+
const offsetMinutes = offsetMatch
|
|
69
|
+
? (offsetMatch[1] === "-" ? -1 : 1) * (Number(offsetMatch[2]) * 60 + Number(offsetMatch[3]))
|
|
70
|
+
: 0;
|
|
71
|
+
const localDate = new Date(ms + offsetMinutes * 60_000).toISOString().slice(0, 10);
|
|
72
|
+
if (localDate !== value.slice(0, 10))
|
|
73
|
+
return undefined;
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
/** Where the switch stands at `now`. */
|
|
77
|
+
export function readRepricingSwitch(options = {}) {
|
|
78
|
+
const env = options.env ?? process.env;
|
|
79
|
+
const raw = env[REPRICING_2026_09_EFFECTIVE_AT_ENV_VAR];
|
|
80
|
+
const effectiveAt = parseRepricingEffectiveAt(raw);
|
|
81
|
+
if (effectiveAt === null)
|
|
82
|
+
return { status: "unset", effectiveAt: null };
|
|
83
|
+
if (effectiveAt === undefined)
|
|
84
|
+
return { status: "invalid", effectiveAt: null, raw: (raw ?? "").trim() };
|
|
85
|
+
const now = options.now === undefined ? Date.now() : Number(options.now);
|
|
86
|
+
return now >= effectiveAt.getTime()
|
|
87
|
+
? { status: "in_force", effectiveAt }
|
|
88
|
+
: { status: "scheduled", effectiveAt };
|
|
89
|
+
}
|
|
90
|
+
/** True once the switch names an instant that has passed. */
|
|
91
|
+
export function isRepricingInForce(options = {}) {
|
|
92
|
+
return readRepricingSwitch(options).status === "in_force";
|
|
93
|
+
}
|
|
94
|
+
/** The value in force: `before` until the switch's instant, `after` from it on. */
|
|
95
|
+
export function repricedValue(value, options = {}) {
|
|
96
|
+
return (isRepricingInForce(options) ? value.after : value.before);
|
|
97
|
+
}
|
|
98
|
+
export function defineRepricedValue(definition) {
|
|
99
|
+
return Object.freeze({ ...definition });
|
|
100
|
+
}
|
|
101
|
+
const GIB = 1024 ** 3;
|
|
102
|
+
// S50 (decision L1, ratified 2026-09-26): the storage limits that TIGHTEN, per
|
|
103
|
+
// plan band. Today every band has 3,000,000 rows per Table, 25,000,000 per
|
|
104
|
+
// workspace and 30 GiB (warning at 20 GiB). $199 and $499 keep those values, and
|
|
105
|
+
// $999 / $1,999 rise only after the capacity test at 5M and 10M rows, so only
|
|
106
|
+
// free, $49 and $99 appear here. The database warning is PROPOSED P-48: two
|
|
107
|
+
// thirds of the hard limit, rounded down to a whole byte.
|
|
108
|
+
// `packages/shared/src/table-capacity.ts` resolves these; a test pins each
|
|
109
|
+
// `after` to the ratified ladder there, so the two cannot drift.
|
|
110
|
+
const STORAGE_CUTS = [
|
|
111
|
+
{ band: "free", tableRows: 100_000, workspaceRows: 1_000_000, databaseBytes: 2 * GIB },
|
|
112
|
+
{ band: "49", tableRows: 1_000_000, workspaceRows: 5_000_000, databaseBytes: 10 * GIB },
|
|
113
|
+
{ band: "99", tableRows: 2_000_000, workspaceRows: 10_000_000, databaseBytes: 20 * GIB },
|
|
114
|
+
];
|
|
115
|
+
function storageCutEntries() {
|
|
116
|
+
return STORAGE_CUTS.flatMap(({ band, tableRows, workspaceRows, databaseBytes }) => {
|
|
117
|
+
const plan = band === "free" ? "the free plan" : `the $${band} plan`;
|
|
118
|
+
const entry = (field, unit, description, before, after) => defineRepricedValue({
|
|
119
|
+
key: `storage.${band}.${field}`,
|
|
120
|
+
kind: "limit",
|
|
121
|
+
slice: "S50",
|
|
122
|
+
decision: "L1",
|
|
123
|
+
unit,
|
|
124
|
+
description,
|
|
125
|
+
before,
|
|
126
|
+
after,
|
|
127
|
+
});
|
|
128
|
+
return [
|
|
129
|
+
entry("table_row_limit", "rows", `Rows per Table on ${plan}.`, 3_000_000, tableRows),
|
|
130
|
+
entry("workspace_row_limit", "rows", `Retained Table rows per workspace on ${plan}.`, 25_000_000, workspaceRows),
|
|
131
|
+
entry("workspace_database_limit_bytes", "bytes", `Workspace Table database hard limit on ${plan}.`, 30 * GIB, databaseBytes),
|
|
132
|
+
entry("workspace_database_warning_bytes", "bytes", `Workspace Table database warning on ${plan}.`, 20 * GIB, Math.floor((databaseBytes * 2) / 3)),
|
|
133
|
+
];
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// ---- S52 (L3.3): email sending per mailbox. -------------------------------
|
|
137
|
+
// The new default of 25 a day is not scheduled: new mailboxes were created at
|
|
138
|
+
// 18 a day, so 25 is an increase and ships live (`@oxygen/shared/sending-limits`).
|
|
139
|
+
// What tightens is the new hard maximum, and the move of mailboxes still on the
|
|
140
|
+
// old column default of 40.
|
|
141
|
+
/** The hard per-mailbox maximum: none before the instant, 50 a day from it. */
|
|
142
|
+
export const EMAIL_MAILBOX_DAILY_CAP_MAXIMUM = defineRepricedValue({
|
|
143
|
+
key: "sending.email_mailbox_daily_cap_max",
|
|
144
|
+
kind: "limit",
|
|
145
|
+
slice: "S52",
|
|
146
|
+
decision: "L3.3",
|
|
147
|
+
unit: "emails_per_mailbox_per_day",
|
|
148
|
+
description: "Most emails one mailbox may send a day. A higher cap is refused, and a mailbox set higher sends at this maximum.",
|
|
149
|
+
before: null,
|
|
150
|
+
after: 50,
|
|
151
|
+
});
|
|
152
|
+
/**
|
|
153
|
+
* A mailbox still on the old default of 40 a day, which nobody set by hand, sends
|
|
154
|
+
* at the new default of 25 (P-58, proposed). The column was `DEFAULT 40`, so a
|
|
155
|
+
* deliberate 40 set before `daily_cap_source` existed cannot be told apart;
|
|
156
|
+
* setting the cap again records it as the customer's own, and it is then kept.
|
|
157
|
+
*/
|
|
158
|
+
export const EMAIL_MAILBOX_OLD_DEFAULT_DAILY_CAP = defineRepricedValue({
|
|
159
|
+
key: "sending.email_mailbox_old_default_daily_cap",
|
|
160
|
+
kind: "limit",
|
|
161
|
+
slice: "S52",
|
|
162
|
+
decision: "L3.3 (P-58)",
|
|
163
|
+
unit: "emails_per_mailbox_per_day",
|
|
164
|
+
description: "A mailbox still on the old default of 40 a day, a cap you never set yourself, sends at the new default of 25.",
|
|
165
|
+
before: 40,
|
|
166
|
+
after: 25,
|
|
167
|
+
});
|
|
168
|
+
/**
|
|
169
|
+
* S20: the monthly credit reservation of every connected account (decisions
|
|
170
|
+
* 2.1–2.4). Each key is the pricing-seed charge key, so the seed row carries the
|
|
171
|
+
* new price and `repricedChargeCredits` holds the old one until the instant.
|
|
172
|
+
*
|
|
173
|
+
* A `before` of 0 means the kind had no credit reservation at all: X accounts
|
|
174
|
+
* were never priced, and a phone number was paid through its $10 Stripe seat
|
|
175
|
+
* rather than in credits. Such a kind reserves nothing until the instant.
|
|
176
|
+
*/
|
|
177
|
+
export const S20_RESERVATION_PRICE_RISES = [
|
|
178
|
+
defineRepricedValue({
|
|
179
|
+
key: "seat.linkedin_account",
|
|
180
|
+
kind: "price",
|
|
181
|
+
slice: "S20",
|
|
182
|
+
decision: "2.1",
|
|
183
|
+
unit: "credits_per_account_per_month",
|
|
184
|
+
description: "Monthly credit reservation for a connected LinkedIn account.",
|
|
185
|
+
before: 1_000,
|
|
186
|
+
after: 3_400,
|
|
187
|
+
}),
|
|
188
|
+
defineRepricedValue({
|
|
189
|
+
key: "seat.x_account",
|
|
190
|
+
kind: "price",
|
|
191
|
+
slice: "S20",
|
|
192
|
+
decision: "2.2",
|
|
193
|
+
unit: "credits_per_account_per_month",
|
|
194
|
+
description: "Monthly credit reservation for a connected X account.",
|
|
195
|
+
before: 0,
|
|
196
|
+
after: 1_000,
|
|
197
|
+
}),
|
|
198
|
+
defineRepricedValue({
|
|
199
|
+
key: "seat.whatsapp_account",
|
|
200
|
+
kind: "price",
|
|
201
|
+
slice: "S20",
|
|
202
|
+
decision: "2.3",
|
|
203
|
+
unit: "credits_per_number_per_month",
|
|
204
|
+
description: "Monthly credit reservation for a connected WhatsApp number.",
|
|
205
|
+
before: 1_000,
|
|
206
|
+
after: 3_000,
|
|
207
|
+
}),
|
|
208
|
+
defineRepricedValue({
|
|
209
|
+
key: "seat.phone_number",
|
|
210
|
+
kind: "price",
|
|
211
|
+
slice: "S20",
|
|
212
|
+
decision: "2.4",
|
|
213
|
+
unit: "credits_per_number_per_month",
|
|
214
|
+
description: "Monthly credit reservation for a rented phone number.",
|
|
215
|
+
before: 0,
|
|
216
|
+
after: 1_000,
|
|
217
|
+
}),
|
|
218
|
+
];
|
|
219
|
+
/**
|
|
220
|
+
* Every value the 2026-09 switch moves. Each dark slice appends its entries
|
|
221
|
+
* here, so `oxygen limits show` can list them once a date is set and one test
|
|
222
|
+
* proves each resolves to its old value before the instant and its new value
|
|
223
|
+
* after it.
|
|
224
|
+
*/
|
|
225
|
+
export const REPRICING_2026_09_SCHEDULE = [
|
|
226
|
+
...storageCutEntries(),
|
|
227
|
+
...S20_RESERVATION_PRICE_RISES,
|
|
228
|
+
EMAIL_MAILBOX_DAILY_CAP_MAXIMUM,
|
|
229
|
+
EMAIL_MAILBOX_OLD_DEFAULT_DAILY_CAP,
|
|
230
|
+
];
|
|
231
|
+
/** The schedule entry with this key, or undefined. */
|
|
232
|
+
export function findRepricedValue(key, schedule = REPRICING_2026_09_SCHEDULE) {
|
|
233
|
+
return schedule.find((entry) => entry.key === key);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The credits a recurring charge costs right now, given the price stored in the
|
|
237
|
+
* pricing book (database row or seed).
|
|
238
|
+
*
|
|
239
|
+
* The seed and the S20 migration carry the NEW price, so a charge key with a
|
|
240
|
+
* scheduled rise must not reach a customer before the instant. Until then a
|
|
241
|
+
* stored price equal to the scheduled new price resolves to the old one. Any
|
|
242
|
+
* other stored price is a staff override made at /admin/costs and stands, as it
|
|
243
|
+
* always has. From the instant on the stored price stands. A key with no
|
|
244
|
+
* scheduled rise passes through unchanged. A result of 0 means "not priced yet":
|
|
245
|
+
* callers that reserve credits treat it as unpriced, never as free.
|
|
246
|
+
*/
|
|
247
|
+
export function repricedChargeCredits(chargeKey, storedCredits, options = {}) {
|
|
248
|
+
const entry = REPRICING_2026_09_SCHEDULE.find((candidate) => candidate.key === chargeKey && candidate.kind === "price" && typeof candidate.before === "number");
|
|
249
|
+
if (!entry || isRepricingInForce(options))
|
|
250
|
+
return storedCredits;
|
|
251
|
+
return storedCredits === entry.after ? entry.before : storedCredits;
|
|
252
|
+
}
|
|
253
|
+
const KEY_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/;
|
|
254
|
+
const SLICE_PATTERN = /^S\d{2}[a-z]?$/;
|
|
255
|
+
/** Human-readable problems with a schedule; empty when it is sound. */
|
|
256
|
+
export function findRepricingScheduleViolations(schedule = REPRICING_2026_09_SCHEDULE) {
|
|
257
|
+
const violations = [];
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
for (const entry of schedule) {
|
|
260
|
+
const label = entry.key || "(empty key)";
|
|
261
|
+
if (!KEY_PATTERN.test(entry.key))
|
|
262
|
+
violations.push(`${label}: key must be dotted snake_case.`);
|
|
263
|
+
if (seen.has(entry.key))
|
|
264
|
+
violations.push(`${label}: duplicate key.`);
|
|
265
|
+
seen.add(entry.key);
|
|
266
|
+
if (!SLICE_PATTERN.test(entry.slice))
|
|
267
|
+
violations.push(`${label}: slice must name a specification slice such as S10.`);
|
|
268
|
+
if (!entry.decision.trim())
|
|
269
|
+
violations.push(`${label}: decision must name the decision-record number.`);
|
|
270
|
+
if (!entry.unit.trim() || !entry.description.trim())
|
|
271
|
+
violations.push(`${label}: unit and description are required.`);
|
|
272
|
+
if (entry.after === null) {
|
|
273
|
+
violations.push(`${label}: a value that becomes unbounded is a loosening and ships live on release.`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (entry.before === null) {
|
|
277
|
+
if (entry.kind !== "limit")
|
|
278
|
+
violations.push(`${label}: only a limit may be unbounded before the switch.`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (typeof entry.before !== typeof entry.after) {
|
|
282
|
+
violations.push(`${label}: before and after must have the same type.`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (entry.before === entry.after)
|
|
286
|
+
violations.push(`${label}: before and after are equal, so nothing changes.`);
|
|
287
|
+
if (entry.kind === "price"
|
|
288
|
+
&& typeof entry.before === "number"
|
|
289
|
+
&& typeof entry.after === "number"
|
|
290
|
+
&& !(entry.after > entry.before)) {
|
|
291
|
+
violations.push(`${label}: only a price rise waits for the switch; a decrease ships live on release.`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return violations;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* What customer surfaces report about the scheduled change. `null` while the
|
|
298
|
+
* switch is unset or unparseable: nothing is announced until an operator sets a
|
|
299
|
+
* valid instant, so an inert switch changes no customer-visible output.
|
|
300
|
+
*/
|
|
301
|
+
export function describeRepricingSchedule(options = {}) {
|
|
302
|
+
const state = readRepricingSwitch(options);
|
|
303
|
+
if (state.status !== "scheduled" && state.status !== "in_force")
|
|
304
|
+
return null;
|
|
305
|
+
const inForce = state.status === "in_force";
|
|
306
|
+
return {
|
|
307
|
+
name: REPRICING_2026_09_NAME,
|
|
308
|
+
effective_at: state.effectiveAt.toISOString(),
|
|
309
|
+
status: state.status,
|
|
310
|
+
changes: (options.schedule ?? REPRICING_2026_09_SCHEDULE).map((entry) => ({
|
|
311
|
+
key: entry.key,
|
|
312
|
+
kind: entry.kind,
|
|
313
|
+
unit: entry.unit,
|
|
314
|
+
description: entry.description,
|
|
315
|
+
value_in_force: inForce ? entry.after : entry.before,
|
|
316
|
+
value_before: entry.before,
|
|
317
|
+
value_after: entry.after,
|
|
318
|
+
})),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type RepricingOptions } from "./repricing.js";
|
|
2
|
+
/**
|
|
3
|
+
* The daily cap a new mailbox gets when nobody names one. New mailboxes were
|
|
4
|
+
* created at 18 a day before the repricing, so this is an increase and is live.
|
|
5
|
+
*/
|
|
6
|
+
export declare const EMAIL_MAILBOX_DEFAULT_DAILY_CAP = 25;
|
|
7
|
+
/**
|
|
8
|
+
* Who set a mailbox's stored cap. `default` = OXYGEN's default at creation,
|
|
9
|
+
* `user` = a person or agent set it. A row written before the column existed
|
|
10
|
+
* has no source (null): its cap may be the old column default or a choice.
|
|
11
|
+
*/
|
|
12
|
+
export declare const EMAIL_MAILBOX_DAILY_CAP_SOURCES: readonly ["default", "user"];
|
|
13
|
+
export type EmailMailboxDailyCapSource = (typeof EMAIL_MAILBOX_DAILY_CAP_SOURCES)[number];
|
|
14
|
+
export declare function readEmailMailboxDailyCapSource(value: unknown): EmailMailboxDailyCapSource | null;
|
|
15
|
+
/** The most a mailbox may be set to send a day, or null while there is no maximum. */
|
|
16
|
+
export declare function emailMailboxDailyCapMaximum(options?: RepricingOptions): number | null;
|
|
17
|
+
/**
|
|
18
|
+
* The configured cap in force for a stored one: every surface that reads a
|
|
19
|
+
* mailbox's cap, and the send claim, go through this.
|
|
20
|
+
*
|
|
21
|
+
* Once the switch is in force, a cap still at the old default of 40 that nobody
|
|
22
|
+
* set by hand sends at the new default, and any cap above the maximum sends at
|
|
23
|
+
* the maximum. Before it, the stored cap is returned unchanged.
|
|
24
|
+
*/
|
|
25
|
+
export declare function emailMailboxDailyCapInForce(input: {
|
|
26
|
+
storedCap: number;
|
|
27
|
+
source: EmailMailboxDailyCapSource | null;
|
|
28
|
+
}, options?: RepricingOptions): number;
|
|
29
|
+
/** A new voice number may dial this many times a day once it has warmed up. */
|
|
30
|
+
export declare const VOICE_NUMBER_DEFAULT_DAILY_CAP = 100;
|
|
31
|
+
/** The most a voice number may be set to dial a day. */
|
|
32
|
+
export declare const VOICE_NUMBER_MAX_DAILY_CAP = 300;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Per-channel sending limits that are the same on every plan (2026-09 repricing,
|
|
2
|
+
// decision L3; specification slice S52). Limits guard, credits meter: none of
|
|
3
|
+
// these is sold, and none charges for sending.
|
|
4
|
+
//
|
|
5
|
+
// Email per mailbox: warm-up ramp 5 / 10 / 20 (tenant-db `WARMUP_RAMP_DAILY_CAPS`),
|
|
6
|
+
// then the mailbox's own daily cap, 25 by default. The hard maximum of 50 and the
|
|
7
|
+
// move of mailboxes still on the old default of 40 are limit cuts, held by the
|
|
8
|
+
// 2026-09 effective-date switch (`./repricing.ts`), so they are read through it.
|
|
9
|
+
//
|
|
10
|
+
// Voice per number: 100 dials a day by default, adjustable up to 300.
|
|
11
|
+
import { EMAIL_MAILBOX_DAILY_CAP_MAXIMUM, EMAIL_MAILBOX_OLD_DEFAULT_DAILY_CAP, repricedValue, } from "./repricing.js";
|
|
12
|
+
/**
|
|
13
|
+
* The daily cap a new mailbox gets when nobody names one. New mailboxes were
|
|
14
|
+
* created at 18 a day before the repricing, so this is an increase and is live.
|
|
15
|
+
*/
|
|
16
|
+
export const EMAIL_MAILBOX_DEFAULT_DAILY_CAP = 25;
|
|
17
|
+
/**
|
|
18
|
+
* Who set a mailbox's stored cap. `default` = OXYGEN's default at creation,
|
|
19
|
+
* `user` = a person or agent set it. A row written before the column existed
|
|
20
|
+
* has no source (null): its cap may be the old column default or a choice.
|
|
21
|
+
*/
|
|
22
|
+
export const EMAIL_MAILBOX_DAILY_CAP_SOURCES = ["default", "user"];
|
|
23
|
+
export function readEmailMailboxDailyCapSource(value) {
|
|
24
|
+
return value === "default" || value === "user" ? value : null;
|
|
25
|
+
}
|
|
26
|
+
/** The most a mailbox may be set to send a day, or null while there is no maximum. */
|
|
27
|
+
export function emailMailboxDailyCapMaximum(options = {}) {
|
|
28
|
+
return repricedValue(EMAIL_MAILBOX_DAILY_CAP_MAXIMUM, options);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The configured cap in force for a stored one: every surface that reads a
|
|
32
|
+
* mailbox's cap, and the send claim, go through this.
|
|
33
|
+
*
|
|
34
|
+
* Once the switch is in force, a cap still at the old default of 40 that nobody
|
|
35
|
+
* set by hand sends at the new default, and any cap above the maximum sends at
|
|
36
|
+
* the maximum. Before it, the stored cap is returned unchanged.
|
|
37
|
+
*/
|
|
38
|
+
export function emailMailboxDailyCapInForce(input, options = {}) {
|
|
39
|
+
const stored = Number.isFinite(input.storedCap) ? Math.max(0, Math.floor(input.storedCap)) : 0;
|
|
40
|
+
const cap = input.source !== "user" && stored === EMAIL_MAILBOX_OLD_DEFAULT_DAILY_CAP.before
|
|
41
|
+
? repricedValue(EMAIL_MAILBOX_OLD_DEFAULT_DAILY_CAP, options)
|
|
42
|
+
: stored;
|
|
43
|
+
const maximum = emailMailboxDailyCapMaximum(options);
|
|
44
|
+
return maximum === null ? cap : Math.min(cap, maximum);
|
|
45
|
+
}
|
|
46
|
+
/** A new voice number may dial this many times a day once it has warmed up. */
|
|
47
|
+
export const VOICE_NUMBER_DEFAULT_DAILY_CAP = 100;
|
|
48
|
+
/** The most a voice number may be set to dial a day. */
|
|
49
|
+
export const VOICE_NUMBER_MAX_DAILY_CAP = 300;
|
|
@@ -172,8 +172,11 @@ function classifySequenceFailure(input) {
|
|
|
172
172
|
.toLowerCase();
|
|
173
173
|
// Ambiguous effects win every other classification. A 429-shaped code or a
|
|
174
174
|
// provider name must never make a possibly-applied write safe to replay.
|
|
175
|
+
// `write[_-]?ambiguous` is the LinkedIn client's `provider_write_ambiguous`:
|
|
176
|
+
// stored rows written before that error carried `effect_outcome` would
|
|
177
|
+
// otherwise read back as a recipient hard bounce.
|
|
175
178
|
if (input.effectOutcome === "unknown" ||
|
|
176
|
-
/(?:effect|outcome)[_-]?unknown|ambiguous[_-]?(?:effect|write)/i.test(codes)) {
|
|
179
|
+
/(?:effect|outcome)[_-]?unknown|ambiguous[_-]?(?:effect|write)|write[_-]?ambiguous/i.test(codes)) {
|
|
177
180
|
return "effect_unknown";
|
|
178
181
|
}
|
|
179
182
|
if ([input.code, input.providerSubtype].some((code) => isProviderFundingErrorCode(code)))
|
|
@@ -17,6 +17,25 @@ export declare const PRICING_PLANS: {
|
|
|
17
17
|
/** The six purchasable rungs with their Stripe price ids, cheapest first. */
|
|
18
18
|
export declare const PURCHASABLE_PLANS: Record<OxygenPlanKey, PricingPlan>;
|
|
19
19
|
export declare function getPlanPriceId(plan: PricingPlan, currency: BillingCurrency): string;
|
|
20
|
+
/** How often a plan Price bills. Credits are granted monthly on both. */
|
|
21
|
+
export type PlanBillingInterval = "month" | "year";
|
|
22
|
+
/**
|
|
23
|
+
* The configured Price for one rung and interval, or "" when that Price is not
|
|
24
|
+
* configured (every yearly Price, until S14 creates them).
|
|
25
|
+
*/
|
|
26
|
+
export declare function getPurchasablePlanPriceId(planKey: OxygenPlanKey, interval: PlanBillingInterval, currency?: BillingCurrency): string;
|
|
27
|
+
/**
|
|
28
|
+
* The billing interval of a recognized plan Price. "year" only for a configured
|
|
29
|
+
* yearly Oxygen Price; every other recognized Price (monthly rungs and every
|
|
30
|
+
* grandfathered or legacy plan) is "month"; an unrecognized id is null.
|
|
31
|
+
*
|
|
32
|
+
* This is the one definition the credit-grant paths read, so the Stripe webhook,
|
|
33
|
+
* the lazy grant on a balance read and the slice sweep can never disagree about
|
|
34
|
+
* whether a paid period is sliced.
|
|
35
|
+
*/
|
|
36
|
+
export declare function planBillingIntervalForPriceId(priceId: string | null | undefined): PlanBillingInterval | null;
|
|
37
|
+
/** Every configured yearly plan Price id (empty until S14). */
|
|
38
|
+
export declare function configuredAnnualPlanPriceIds(): readonly string[];
|
|
20
39
|
/**
|
|
21
40
|
* Resolve a Stripe price id to the plan key stored in `subscriptions.tier`.
|
|
22
41
|
*
|
|
@@ -30,6 +49,11 @@ export declare function tierFromPriceId(priceId: string): string | null;
|
|
|
30
49
|
* Complete runtime-recognized Price set grouped by the current self-serve
|
|
31
50
|
* family. Catalog audit/migration code consumes this instead of maintaining a
|
|
32
51
|
* second env map that can silently omit grandfathered subscriptions.
|
|
52
|
+
*
|
|
53
|
+
* The yearly Prices are deliberately NOT listed yet: the migration planner
|
|
54
|
+
* matches an outgoing Oxygen Price to its target by amount, and must learn to
|
|
55
|
+
* compare intervals (repricing slice S16) before a yearly Price may enter its
|
|
56
|
+
* source set. S14 adds them when it creates the Prices.
|
|
33
57
|
*/
|
|
34
58
|
export declare function recognizedStripePriceIdsBySelfServeTier(): Record<SelfServePlanTier, readonly string[]>;
|
|
35
59
|
export declare function formatMonthlyPrice(priceCents: number | null, currency?: BillingCurrency): string;
|