@rayadesu/dsh-llm-billing 0.3.16 → 0.3.17

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.
@@ -54,6 +54,11 @@ const _rayadesu_dsh_llm_billing_billing_getSessionSpend_result$schema = () => (_
54
54
  'cacheMissInputCost': z.number(),
55
55
  'outputCost': z.number(),
56
56
  })),
57
+ 'unpriced': z.union([z.undefined(), z.object({
58
+ 'events': z.number(),
59
+ 'tokens': z.number(),
60
+ 'models': z.array(z.string()),
61
+ })]).optional(),
57
62
  }))
58
63
  let _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_parameter_0$schema$value
59
64
  const _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_parameter_0$schema = () => (_rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown()))
@@ -93,6 +98,11 @@ const _rayadesu_dsh_llm_billing_billing_getTodaySpend_result$schema = () => (_ra
93
98
  'cacheMissInputCost': z.number(),
94
99
  'outputCost': z.number(),
95
100
  })),
101
+ 'unpriced': z.union([z.undefined(), z.object({
102
+ 'events': z.number(),
103
+ 'tokens': z.number(),
104
+ 'models': z.array(z.string()),
105
+ })]).optional(),
96
106
  }))
97
107
  let _rayadesu_dsh_llm_billing_billing_getTurnSpend_parameter_0$schema$value
98
108
  const _rayadesu_dsh_llm_billing_billing_getTurnSpend_parameter_0$schema = () => (_rayadesu_dsh_llm_billing_billing_getTurnSpend_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown()))
@@ -133,7 +143,7 @@ export const TYPERT = {
133
143
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekBalance',
134
144
  create: _rayadesu_dsh_llm_billing_billing_getBalance_result$schema,
135
145
  },
136
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":172,"column":3},
146
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":258,"column":3},
137
147
  },
138
148
  {
139
149
  id: '@rayadesu/dsh-llm-billing#billing/getDelegatedSpend',
@@ -169,7 +179,7 @@ export const TYPERT = {
169
179
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekDelegatedSpend',
170
180
  create: _rayadesu_dsh_llm_billing_billing_getDelegatedSpend_result$schema,
171
181
  },
172
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":226,"column":3},
182
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":312,"column":3},
173
183
  },
174
184
  {
175
185
  id: '@rayadesu/dsh-llm-billing#billing/getSessionSpend',
@@ -194,7 +204,7 @@ export const TYPERT = {
194
204
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekSessionSpend',
195
205
  create: _rayadesu_dsh_llm_billing_billing_getSessionSpend_result$schema,
196
206
  },
197
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":183,"column":3},
207
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":269,"column":3},
198
208
  },
199
209
  {
200
210
  id: '@rayadesu/dsh-llm-billing#billing/getSessionTurnSpends',
@@ -219,7 +229,7 @@ export const TYPERT = {
219
229
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekSessionTurnSpends',
220
230
  create: _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_result$schema,
221
231
  },
222
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":251,"column":3},
232
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":337,"column":3},
223
233
  },
224
234
  {
225
235
  id: '@rayadesu/dsh-llm-billing#billing/getTodaySessionsSpend',
@@ -245,7 +255,7 @@ export const TYPERT = {
245
255
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekTodaySessionsSpend',
246
256
  create: _rayadesu_dsh_llm_billing_billing_getTodaySessionsSpend_result$schema,
247
257
  },
248
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":211,"column":3},
258
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":297,"column":3},
249
259
  },
250
260
  {
251
261
  id: '@rayadesu/dsh-llm-billing#billing/getTodaySpend',
@@ -271,7 +281,7 @@ export const TYPERT = {
271
281
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekTodaySpend',
272
282
  create: _rayadesu_dsh_llm_billing_billing_getTodaySpend_result$schema,
273
283
  },
274
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":196,"column":3},
284
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":282,"column":3},
275
285
  },
276
286
  {
277
287
  id: '@rayadesu/dsh-llm-billing#billing/getTurnSpend',
@@ -306,7 +316,7 @@ export const TYPERT = {
306
316
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekTurnSpend',
307
317
  create: _rayadesu_dsh_llm_billing_billing_getTurnSpend_result$schema,
308
318
  },
309
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":239,"column":3},
319
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":325,"column":3},
310
320
  },
311
321
  ],
312
322
  model: {
@@ -54,6 +54,11 @@ const _rayadesu_dsh_llm_billing_billing_getSessionSpend_result$schema = () => (_
54
54
  'cacheMissInputCost': z.number(),
55
55
  'outputCost': z.number(),
56
56
  })),
57
+ 'unpriced': z.union([z.undefined(), z.object({
58
+ 'events': z.number(),
59
+ 'tokens': z.number(),
60
+ 'models': z.array(z.string()),
61
+ })]).optional(),
57
62
  }))
58
63
  let _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_parameter_0$schema$value
59
64
  const _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_parameter_0$schema = () => (_rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown()))
@@ -93,6 +98,11 @@ const _rayadesu_dsh_llm_billing_billing_getTodaySpend_result$schema = () => (_ra
93
98
  'cacheMissInputCost': z.number(),
94
99
  'outputCost': z.number(),
95
100
  })),
101
+ 'unpriced': z.union([z.undefined(), z.object({
102
+ 'events': z.number(),
103
+ 'tokens': z.number(),
104
+ 'models': z.array(z.string()),
105
+ })]).optional(),
96
106
  }))
97
107
  let _rayadesu_dsh_llm_billing_billing_getTurnSpend_parameter_0$schema$value
98
108
  const _rayadesu_dsh_llm_billing_billing_getTurnSpend_parameter_0$schema = () => (_rayadesu_dsh_llm_billing_billing_getTurnSpend_parameter_0$schema$value ??= z.intersection(z.string(), z.unknown()))
@@ -130,7 +140,7 @@ export const TYPERT_REMOTE = {
130
140
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekBalance',
131
141
  create: _rayadesu_dsh_llm_billing_billing_getBalance_result$schema,
132
142
  },
133
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":172,"column":3},
143
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":258,"column":3},
134
144
  },
135
145
  {
136
146
  id: '@rayadesu/dsh-llm-billing#billing/getDelegatedSpend',
@@ -166,7 +176,7 @@ export const TYPERT_REMOTE = {
166
176
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekDelegatedSpend',
167
177
  create: _rayadesu_dsh_llm_billing_billing_getDelegatedSpend_result$schema,
168
178
  },
169
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":226,"column":3},
179
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":312,"column":3},
170
180
  },
171
181
  {
172
182
  id: '@rayadesu/dsh-llm-billing#billing/getSessionSpend',
@@ -191,7 +201,7 @@ export const TYPERT_REMOTE = {
191
201
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekSessionSpend',
192
202
  create: _rayadesu_dsh_llm_billing_billing_getSessionSpend_result$schema,
193
203
  },
194
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":183,"column":3},
204
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":269,"column":3},
195
205
  },
196
206
  {
197
207
  id: '@rayadesu/dsh-llm-billing#billing/getSessionTurnSpends',
@@ -216,7 +226,7 @@ export const TYPERT_REMOTE = {
216
226
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekSessionTurnSpends',
217
227
  create: _rayadesu_dsh_llm_billing_billing_getSessionTurnSpends_result$schema,
218
228
  },
219
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":251,"column":3},
229
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":337,"column":3},
220
230
  },
221
231
  {
222
232
  id: '@rayadesu/dsh-llm-billing#billing/getTodaySessionsSpend',
@@ -242,7 +252,7 @@ export const TYPERT_REMOTE = {
242
252
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekTodaySessionsSpend',
243
253
  create: _rayadesu_dsh_llm_billing_billing_getTodaySessionsSpend_result$schema,
244
254
  },
245
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":211,"column":3},
255
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":297,"column":3},
246
256
  },
247
257
  {
248
258
  id: '@rayadesu/dsh-llm-billing#billing/getTodaySpend',
@@ -268,7 +278,7 @@ export const TYPERT_REMOTE = {
268
278
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekTodaySpend',
269
279
  create: _rayadesu_dsh_llm_billing_billing_getTodaySpend_result$schema,
270
280
  },
271
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":196,"column":3},
281
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":282,"column":3},
272
282
  },
273
283
  {
274
284
  id: '@rayadesu/dsh-llm-billing#billing/getTurnSpend',
@@ -303,7 +313,7 @@ export const TYPERT_REMOTE = {
303
313
  typeSymbol: '@rayadesu/dsh-llm-billing/types#DeepSeekTurnSpend',
304
314
  create: _rayadesu_dsh_llm_billing_billing_getTurnSpend_result$schema,
305
315
  },
306
- sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":239,"column":3},
316
+ sourceLocation: {"file":"packages/llm-billing/src/balance.ts","line":325,"column":3},
307
317
  },
308
318
  ],
309
319
  }
@@ -17,8 +17,18 @@ import type { DeepSeekBalance, DeepSeekDelegatedSpend, DeepSeekSessionSpend, Dee
17
17
  * @throws {@link LlmError} with code `TRANSPORT` when the body is malformed.
18
18
  */
19
19
  export declare function parseDeepSeekBalance(body: unknown): DeepSeekBalance;
20
+ /** Attempts one balance fetch makes before giving up on a transient fault. */
21
+ export declare const BALANCE_FETCH_ATTEMPTS = 3;
22
+ /** The first retry's delay in milliseconds; doubled for each further attempt. */
23
+ export declare const BALANCE_RETRY_BASE_MS = 150;
20
24
  /**
21
25
  * Fetch one account-balance snapshot from `{baseURL}/user/balance`.
26
+ *
27
+ * Retries the faults that can plausibly clear — a dropped socket, a 429, a 5xx
28
+ * — up to {@link BALANCE_FETCH_ATTEMPTS} attempts, because the badge polls
29
+ * every few minutes and one lost packet would otherwise blank the balance
30
+ * until the next interval. Permanent answers (including a rejected key) fail
31
+ * on the first attempt.
22
32
  * @param baseURL - resolved endpoint base; `/user/balance` is appended.
23
33
  * @param apiKey - resolved bearer token for this endpoint.
24
34
  * @param signal - optional cancellation.
@@ -87,15 +87,41 @@ export function parseDeepSeekBalance(body) {
87
87
  });
88
88
  return { isAvailable, lines };
89
89
  }
90
+ /** Attempts one balance fetch makes before giving up on a transient fault. */
91
+ export const BALANCE_FETCH_ATTEMPTS = 3;
92
+ /** The first retry's delay in milliseconds; doubled for each further attempt. */
93
+ export const BALANCE_RETRY_BASE_MS = 150;
90
94
  /**
91
- * Fetch one account-balance snapshot from `{baseURL}/user/balance`.
92
- * @param baseURL - resolved endpoint base; `/user/balance` is appended.
93
- * @param apiKey - resolved bearer token for this endpoint.
94
- * @param signal - optional cancellation.
95
- * @returns the validated balance snapshot.
96
- * @throws {@link LlmError} for transport, HTTP, or malformed-response failures.
95
+ * Whether another attempt can plausibly succeed. Only faults transient BY
96
+ * NATURE qualify: a dropped socket (no status), a rate limit, or a server-side
97
+ * error. A settled 4xx is final — retrying a bad key three times only reports
98
+ * the truth three times later.
97
99
  */
98
- export async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
100
+ function isRetryableStatus(status) {
101
+ if (status === undefined)
102
+ return true;
103
+ return status === 429 || status >= 500;
104
+ }
105
+ /** Wait `ms` before the next attempt, abandoning the wait if the caller aborts. */
106
+ function waitBalanceRetry(ms, signal) {
107
+ return new Promise((resolve, reject) => {
108
+ if (signal?.aborted === true) {
109
+ reject(signal.reason);
110
+ return;
111
+ }
112
+ const onAbort = () => {
113
+ clearTimeout(timer);
114
+ reject(signal?.reason);
115
+ };
116
+ const timer = setTimeout(() => {
117
+ signal?.removeEventListener('abort', onAbort);
118
+ resolve();
119
+ }, ms);
120
+ signal?.addEventListener('abort', onAbort, { once: true });
121
+ });
122
+ }
123
+ /** One balance request attempt, REPORTED rather than thrown so the caller decides. */
124
+ async function requestBalanceOnce(baseURL, apiKey, signal) {
99
125
  let response;
100
126
  try {
101
127
  response = await fetch(`${baseURL}/user/balance`, {
@@ -108,21 +134,61 @@ export async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
108
134
  });
109
135
  }
110
136
  catch (error) {
111
- if (signal?.aborted)
112
- throw new LlmError('DeepSeek balance request aborted by caller', 'ABORTED', { cause: error });
113
- throw new LlmError(`DeepSeek balance request to ${baseURL} failed`, 'TRANSPORT', { cause: error });
137
+ const wrapped = signal?.aborted === true
138
+ ? new LlmError('DeepSeek balance request aborted by caller', 'ABORTED', { cause: error })
139
+ : new LlmError(`DeepSeek balance request to ${baseURL} failed`, 'TRANSPORT', { cause: error });
140
+ return { ok: false, status: undefined, error: wrapped };
114
141
  }
115
142
  if (!response.ok) {
116
- throw new LlmError(`DeepSeek balance request failed (HTTP ${response.status})`, httpErrorCode(response.status), { status: response.status });
143
+ return {
144
+ ok: false,
145
+ status: response.status,
146
+ error: new LlmError(`DeepSeek balance request failed (HTTP ${response.status})`, httpErrorCode(response.status), { status: response.status }),
147
+ };
117
148
  }
118
149
  let body;
119
150
  try {
120
151
  body = await response.json();
121
152
  }
122
153
  catch (error) {
123
- throw new LlmError('DeepSeek balance response was not valid JSON', 'TRANSPORT', { cause: error });
154
+ // A malformed body is never transient, hence `status` rather than
155
+ // `undefined`: no retry, just the failure.
156
+ return { ok: false, status: response.status, error: new LlmError('DeepSeek balance response was not valid JSON', 'TRANSPORT', { cause: error }) };
157
+ }
158
+ return { ok: true, value: parseDeepSeekBalance(body) };
159
+ }
160
+ /**
161
+ * Fetch one account-balance snapshot from `{baseURL}/user/balance`.
162
+ *
163
+ * Retries the faults that can plausibly clear — a dropped socket, a 429, a 5xx
164
+ * — up to {@link BALANCE_FETCH_ATTEMPTS} attempts, because the badge polls
165
+ * every few minutes and one lost packet would otherwise blank the balance
166
+ * until the next interval. Permanent answers (including a rejected key) fail
167
+ * on the first attempt.
168
+ * @param baseURL - resolved endpoint base; `/user/balance` is appended.
169
+ * @param apiKey - resolved bearer token for this endpoint.
170
+ * @param signal - optional cancellation.
171
+ * @returns the validated balance snapshot.
172
+ * @throws {@link LlmError} for transport, HTTP, or malformed-response failures.
173
+ */
174
+ export async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
175
+ for (let attempt = 1;; attempt += 1) {
176
+ const outcome = await requestBalanceOnce(baseURL, apiKey, signal);
177
+ if (outcome.ok)
178
+ return outcome.value;
179
+ const exhausted = attempt >= BALANCE_FETCH_ATTEMPTS;
180
+ const aborted = signal?.aborted === true;
181
+ if (!exhausted && !aborted && isRetryableStatus(outcome.status)) {
182
+ await waitBalanceRetry(BALANCE_RETRY_BASE_MS * 2 ** (attempt - 1), signal);
183
+ continue;
184
+ }
185
+ // A rejected credential is the one failure a user can act on, and retrying
186
+ // never fixes it: name what to check, not just the status code.
187
+ if (outcome.status === 401 || outcome.status === 403) {
188
+ throw new LlmError(`DeepSeek balance request was rejected (HTTP ${outcome.status}) — the API key in effect for ${baseURL} is missing or unauthorized (check DEEPSEEK_API_KEY)`, httpErrorCode(outcome.status), { status: outcome.status });
189
+ }
190
+ throw outcome.error;
124
191
  }
125
- return parseDeepSeekBalance(body);
126
192
  }
127
193
  /**
128
194
  * Remote-only service exposing the DeepSeek account balance and session spend.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Beijing wall-clock arithmetic (Asia/Shanghai: a fixed UTC+8, no DST) — the
3
+ * calendar fields a sample's rate depends on, and the day boundaries a scan is
4
+ * clipped to. Split out of `billing.ts`: pricing asks this module questions, it
5
+ * never asks about pricing.
6
+ * @module @rayadesu/dsh-llm-billing/beijing-time
7
+ */
8
+ /** One shifted-timestamp view of a Beijing (UTC+8, no DST) instant. */
9
+ export interface BeijingParts {
10
+ /**
11
+ * The instant in epoch milliseconds. Pricing needs it back to resolve the
12
+ * rate revision in effect at the sample's own timestamp (see
13
+ * {@link ratesAt}), so the view carries it instead of a second parse.
14
+ */
15
+ time: number;
16
+ /** Beijing hour, `0`–`23`. */
17
+ hour: number;
18
+ /** Beijing weekday as `getUTCDay()`: `0` is Sunday, `6` is Saturday. */
19
+ weekday: number;
20
+ /** Beijing calendar-day key (`YYYY-MM-DD`). */
21
+ dayKey: string;
22
+ }
23
+ /**
24
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
25
+ * pure integer arithmetic — every timezone-sensitive read shares this one
26
+ * implementation, so the pieces cannot drift apart. Callers that filter by
27
+ * day and then price the same event reuse the returned view, so each event is
28
+ * parsed exactly once. (The hot fold path runs this per committed event; the
29
+ * previous `Date` + `toISOString().slice()` version allocated a `Date` and a
30
+ * 24-character string per call.)
31
+ * @param time - epoch milliseconds.
32
+ * @throws {RangeError} when `time` is not a finite number.
33
+ */
34
+ export declare function beijingPartsOf(time: number): BeijingParts;
35
+ /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
36
+ export declare function beijingDayKey(now: Date): string;
37
+ /**
38
+ * The Beijing calendar day NAMED by `dayKey` as an epoch-millisecond range:
39
+ * `start` inclusive, `end` exclusive. Derived from the key itself rather than
40
+ * from a clock, because a scan prices whatever day it was asked for — today,
41
+ * or a day a caller is replaying.
42
+ *
43
+ * The conversion needs no calendar arithmetic: `Date.UTC` builds midnight UTC
44
+ * of that civil date, and Beijing midnight is a fixed offset earlier (no DST).
45
+ * @param dayKey - a `YYYY-MM-DD` Beijing day key.
46
+ * @returns the day's bounds, or `undefined` when the key is not that shape.
47
+ */
48
+ export declare function beijingDayRangeOfKey(dayKey: string): {
49
+ readonly start: number;
50
+ readonly end: number;
51
+ } | undefined;
52
+ //# sourceMappingURL=beijing-time.d.ts.map
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Beijing wall-clock arithmetic (Asia/Shanghai: a fixed UTC+8, no DST) — the
3
+ * calendar fields a sample's rate depends on, and the day boundaries a scan is
4
+ * clipped to. Split out of `billing.ts`: pricing asks this module questions, it
5
+ * never asks about pricing.
6
+ * @module @rayadesu/dsh-llm-billing/beijing-time
7
+ */
8
+ /** Beijing is a fixed UTC+8 offset with no DST. */
9
+ const BEIJING_OFFSET_MS = 8 * 3_600_000;
10
+ /** Milliseconds in one day. */
11
+ const DAY_MS = 86_400_000;
12
+ /** Epoch day of 1970-01-01 in the civil-date algorithm below. */
13
+ const CIVIL_EPOCH_DAY = 719_468;
14
+ /** Two-digit zero pad for a calendar field. */
15
+ function pad2(value) {
16
+ return value < 10 ? `0${value}` : String(value);
17
+ }
18
+ /**
19
+ * Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
20
+ * pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
21
+ */
22
+ function civilDateOf(epochDay) {
23
+ const shifted = epochDay + CIVIL_EPOCH_DAY;
24
+ const era = Math.floor(shifted / 146_097);
25
+ const dayOfEra = shifted - era * 146_097;
26
+ const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1_460) + Math.floor(dayOfEra / 36_524) - Math.floor(dayOfEra / 146_096)) / 365);
27
+ const year = yearOfEra + era * 400;
28
+ const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
29
+ const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
30
+ const month = monthPrime + (monthPrime < 10 ? 3 : -9);
31
+ return {
32
+ // January/February belong to the civil year AFTER the era year.
33
+ year: month <= 2 ? year + 1 : year,
34
+ month,
35
+ day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1,
36
+ };
37
+ }
38
+ /**
39
+ * Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
40
+ * pure integer arithmetic — every timezone-sensitive read shares this one
41
+ * implementation, so the pieces cannot drift apart. Callers that filter by
42
+ * day and then price the same event reuse the returned view, so each event is
43
+ * parsed exactly once. (The hot fold path runs this per committed event; the
44
+ * previous `Date` + `toISOString().slice()` version allocated a `Date` and a
45
+ * 24-character string per call.)
46
+ * @param time - epoch milliseconds.
47
+ * @throws {RangeError} when `time` is not a finite number.
48
+ */
49
+ export function beijingPartsOf(time) {
50
+ if (!Number.isFinite(time))
51
+ throw new RangeError(`billing: event time is not finite (${String(time)})`);
52
+ const shifted = time + BEIJING_OFFSET_MS;
53
+ const epochDay = Math.floor(shifted / DAY_MS);
54
+ const msOfDay = shifted - epochDay * DAY_MS;
55
+ const civil = civilDateOf(epochDay);
56
+ return {
57
+ time,
58
+ hour: Math.floor(msOfDay / 3_600_000),
59
+ // 1970-01-01 was a Thursday (4).
60
+ weekday: ((epochDay + 4) % 7 + 7) % 7,
61
+ dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`,
62
+ };
63
+ }
64
+ /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
65
+ export function beijingDayKey(now) {
66
+ return beijingPartsOf(now.getTime()).dayKey;
67
+ }
68
+ /**
69
+ * The Beijing calendar day NAMED by `dayKey` as an epoch-millisecond range:
70
+ * `start` inclusive, `end` exclusive. Derived from the key itself rather than
71
+ * from a clock, because a scan prices whatever day it was asked for — today,
72
+ * or a day a caller is replaying.
73
+ *
74
+ * The conversion needs no calendar arithmetic: `Date.UTC` builds midnight UTC
75
+ * of that civil date, and Beijing midnight is a fixed offset earlier (no DST).
76
+ * @param dayKey - a `YYYY-MM-DD` Beijing day key.
77
+ * @returns the day's bounds, or `undefined` when the key is not that shape.
78
+ */
79
+ export function beijingDayRangeOfKey(dayKey) {
80
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayKey);
81
+ if (match === null)
82
+ return undefined;
83
+ const utcMidnight = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
84
+ if (Number.isNaN(utcMidnight))
85
+ return undefined;
86
+ return { start: utcMidnight - BEIJING_OFFSET_MS, end: utcMidnight + DAY_MS - BEIJING_OFFSET_MS };
87
+ }
88
+ //# sourceMappingURL=beijing-time.js.map