convex-weflayr 0.1.1 → 0.2.1

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 CHANGED
@@ -1,30 +1,42 @@
1
1
  # convex-weflayr
2
2
 
3
- A Convex component that sends your app's model calls through the
4
- [Weflayr](https://weflayr.com) gateway, and saves what each call cost, per
5
- customer, in your own database.
3
+ A Convex component for running your app on [Weflayr](https://weflayr.com)
4
+ routing strategies, with every call tagged by the customer it serves. It also
5
+ brings Weflayr's real cost and margin per customer back into your Convex
6
+ database, so your app can show it and act on it.
6
7
 
7
- Weflayr can show cost, run benchmarks and compare models, but only if every
8
- request says which feature, which call and which customer it belongs to. In a
9
- Convex app you would build that header by hand on every call, and the cost data
10
- would stay in Weflayr's dashboard, out of reach of your own code. This component
11
- adds the header for you and writes each call's usage into tables your app can
12
- query. So your app can act on cost: show a customer their usage, warn at 80% of
13
- a plan, stop at 100%.
8
+ Weflayr is a gateway that decides which model answers each call. You benchmark
9
+ your own calls on other models, save the result as a routing strategy, and your
10
+ code sends `model: "strategy/<id>"`. Weflayr then picks the model per call, and
11
+ its routing overrides can treat a customer differently by their cost or their
12
+ margin. All of that depends on each call saying which customer, feature and call
13
+ it belongs to. This component does that in a Convex action, and keeps what comes
14
+ back where your queries can reach it.
14
15
 
15
- ![How a call flows: your Convex action calls the model through the Weflayr gateway, the component saves the call, and your code reads cost and checks budgets](https://raw.githubusercontent.com/sholajegede/convex-weflayr/main/docs/images/flow.png)
16
+ ![How a call flows: your Convex action calls a strategy through the Weflayr gateway, the component saves the call, and sync brings Weflayr's cost and margin back](https://raw.githubusercontent.com/sholajegede/convex-weflayr/main/docs/images/flow.png)
16
17
 
17
18
  ## What you get
18
19
 
19
- - **Cost per customer.** Every call is saved with its customer, call name,
20
- feature, plan, model, tokens, cost, latency and status. Totals by customer,
21
- call, feature and day are one query away, and they update live in your UI.
22
- - **Budgets.** Give a customer a monthly dollar budget. Their next call is
23
- stopped before the model runs, and other customers carry on.
24
- - **Agent runs.** Give every step of a run the same run id. You see what the run
25
- cost, step by step, and you can stop it part way.
26
- - **Cheaper models, with proof.** Each call has a fixed name, so Weflayr can
27
- replay your real calls on other models and score the answers.
20
+ - **The customer on every call.** Read from your login or your own function.
21
+ Per-customer routing overrides, cost and margin in Weflayr all depend on it.
22
+ A call without a customer fails by default, so none go untagged.
23
+ - **Strategies instead of model names.** `strategy("support", 3)` pins a version
24
+ in production and `strategy("support")` follows the latest. Changing which
25
+ model answers is a change in the Weflayr dashboard, not a deploy. Each call is
26
+ saved with the model the strategy picked.
27
+ - **Revenue and plan into Weflayr.** Push revenue from any billing system and
28
+ tag customers with their plan, so margin and per-plan views work.
29
+ - **Weflayr's cost and margin in your database.** `sync` saves each customer's
30
+ real cost, revenue, margin and risk status for the month. Show it in your UI
31
+ with a live query, and stop a customer whose margin status you list.
32
+ - **Budgets.** A monthly dollar budget per customer, checked against that real
33
+ cost. The next call is stopped before the model runs, and other customers
34
+ carry on.
35
+ - **Agent runs.** Every step of a run carries one feature run id, so Weflayr
36
+ prices the whole run. You can also read the run's cost step by step and stop
37
+ it part way.
38
+ - **A per-call record.** Every call is saved with customer, call, feature, plan,
39
+ model, tokens, cost estimate, latency and status, for lists in your own UI.
28
40
  - **Calls that survive a gateway failure.** `withFallback()` can run the call
29
41
  direct if the gateway fails. This is optional.
30
42
  - **A Vercel AI SDK model** for the gateway, if you use the AI SDK.
@@ -114,13 +126,18 @@ npx convex env set WEFLAYR_CUSTOMERS "*"
114
126
  `WEFLAYR_CUSTOMERS` is a comma separated list of customer ids, or `*` for all.
115
127
  When it is empty, no customer goes through the gateway.
116
128
 
129
+ Give the key the Gateway scope for model calls, and the Management scope as well
130
+ for cost, margin, revenue and tags. To use a separate management key, set
131
+ `WEFLAYR_MANAGEMENT_KEY`.
132
+
117
133
  ## Use it
118
134
 
119
- Create one `Weflayr` instance. Name your calls and the feature each one belongs
120
- to. A benchmark in Weflayr is set up per call name, so keep the names stable.
135
+ Create one `Weflayr` instance. Name your calls, the feature each belongs to, and
136
+ the model or strategy each uses. A benchmark in Weflayr is set up per call name,
137
+ so keep the names stable.
121
138
 
122
139
  ```ts
123
- import { Weflayr } from "convex-weflayr";
140
+ import { Weflayr, strategy } from "convex-weflayr";
124
141
  import { components } from "./_generated/api";
125
142
 
126
143
  export const weflayr = new Weflayr(components.weflayr, {
@@ -129,37 +146,99 @@ export const weflayr = new Weflayr(components.weflayr, {
129
146
  "classify-ticket": "support-replies",
130
147
  "summarise-thread": "thread-summaries",
131
148
  },
132
- // Dollars per million tokens, from your provider's price list.
149
+ // Pin a version in production, follow the latest elsewhere.
150
+ models: {
151
+ "draft-reply": strategy("support", process.env.NODE_ENV === "production" ? 4 : undefined),
152
+ "classify-ticket": "claude-haiku-5-5",
153
+ },
154
+ defaultModel: strategy("default"),
155
+ // Who the call is for. The default is the signed-in user's id.
156
+ customer: async (ctx) => (await ctx.auth.getUserIdentity())?.orgId as string | undefined,
157
+ // Stop a customer whose margin is in one of these states.
158
+ stopOnMargin: ["unprofitable"],
159
+ // Dollars per million tokens, for estimates between syncs.
133
160
  prices: {
134
161
  "claude-haiku-5-5": { inputPerMillion: 0.1, outputPerMillion: 0.5 },
135
162
  },
136
163
  });
137
164
  ```
138
165
 
139
- Call a model through the gateway with the AI SDK:
166
+ Call the model through the gateway with the AI SDK. `callMeta` gives the
167
+ customer and the model for the call:
140
168
 
141
169
  ```ts
142
170
  import { generateText } from "ai";
143
171
  import { weflayrModel } from "convex-weflayr/ai-sdk";
144
172
 
145
- const meta = { customer: workspace.name, plan: workspace.plan, call: "classify-ticket" };
173
+ const meta = await weflayr.callMeta(ctx, { call: "draft-reply", plan: workspace.plan });
146
174
 
147
- const result = await weflayr.track(
148
- ctx,
149
- { ...meta, model: "claude-haiku-5-5" },
150
- () =>
151
- generateText({
152
- model: weflayrModel(weflayr, "claude-haiku-5-5"),
153
- headers: weflayr.headers(meta),
154
- prompt,
155
- }),
175
+ const result = await weflayr.track(ctx, meta, () =>
176
+ generateText({
177
+ model: weflayrModel(weflayr, meta.model),
178
+ headers: weflayr.headers(meta),
179
+ prompt,
180
+ }),
156
181
  );
157
182
  ```
158
183
 
159
- `ctx` is any action context. `track` times the call and saves it. If the call
160
- fails, it saves the failure and throws the error again. Without the AI SDK,
161
- `weflayr.connection()` returns the base URL and key for any OpenAI compatible
162
- client.
184
+ `ctx` is any action context. `track` times the call and saves it, with the model
185
+ that answered when the model is a strategy. If the call fails, it saves the
186
+ failure and throws the error again. When the gateway changed the request, for
187
+ example by removing a setting the chosen model does not take, the warning is
188
+ logged and saved with the call. Without the AI SDK, `weflayr.connection()`
189
+ returns the base URL and key for any OpenAI compatible client.
190
+
191
+ A call with no customer throws, so nothing reaches Weflayr untagged. To log a
192
+ warning and send `unknown` instead, set `missingCustomer: "warn"`.
193
+
194
+ ### Push revenue and plan
195
+
196
+ Weflayr computes margin from revenue. If you bill with Stripe, connect it in
197
+ Weflayr and skip this. For any other billing system, push revenue from your
198
+ billing webhook or a nightly job:
199
+
200
+ ```ts
201
+ await weflayr.sendRevenue([
202
+ { customer_name: workspace.id, amount: 49, month: "2026-11", revenue_id: invoice.id },
203
+ ]);
204
+
205
+ await weflayr.tagCustomers({ [workspace.id]: { plan: "pro" } });
206
+ ```
207
+
208
+ Rows are sent as upserts keyed by `revenue_id`, so a retried webhook does not
209
+ book the same invoice twice, and a temporary Weflayr error is retried. Use one
210
+ revenue source per customer and month: Weflayr adds sources together.
211
+
212
+ ### Bring cost and margin back
213
+
214
+ Run `sync` on a schedule. It reads each customer's cost, and margin when the
215
+ project has revenue, for the month and saves them in the component:
216
+
217
+ ```ts
218
+ // convex/crons.ts
219
+ import { cronJobs } from "convex/server";
220
+ import { internal } from "./_generated/api";
221
+
222
+ const crons = cronJobs();
223
+ crons.interval("sync weflayr", { minutes: 10 }, internal.billing.sync, {});
224
+ export default crons;
225
+
226
+ // convex/billing.ts
227
+ export const sync = internalAction({
228
+ args: {},
229
+ handler: async (ctx) => await weflayr.sync(ctx),
230
+ });
231
+ ```
232
+
233
+ Then read it from a query, so your UI updates live:
234
+
235
+ ```ts
236
+ const status = await weflayr.customerStatus(ctx, "acme");
237
+ // { costUsd, revenue, marginPct, riskStatus, syncedAt } or null before the first sync
238
+ ```
239
+
240
+ `riskStatus` is `healthy`, `watch`, `at_risk` or `unprofitable`. On a project
241
+ without revenue the cost is saved and the margin fields are empty.
163
242
 
164
243
  ### Streaming
165
244
 
@@ -170,13 +249,12 @@ result only has its usage when the stream ends, so record the call yourself in
170
249
  ```ts
171
250
  const started = Date.now();
172
251
  const result = streamText({
173
- model: weflayrModel(weflayr, "claude-haiku-5-5"),
252
+ model: weflayrModel(weflayr, meta.model),
174
253
  headers: weflayr.headers(meta),
175
254
  prompt,
176
255
  onFinish: async ({ usage }) => {
177
256
  await weflayr.record(ctx, {
178
257
  ...meta,
179
- model: "claude-haiku-5-5",
180
258
  inputTokens: usage.inputTokens ?? 0,
181
259
  outputTokens: usage.outputTokens ?? 0,
182
260
  latencyMs: Date.now() - started,
@@ -195,10 +273,10 @@ your own provider key.
195
273
  ```ts
196
274
  const result = await weflayr.withFallback(
197
275
  ctx,
198
- { ...meta, model },
276
+ meta,
199
277
  {
200
- gateway: () => generateText({ model: weflayrModel(weflayr, model), headers: weflayr.headers(meta), prompt }),
201
- direct: () => generateText({ model: yourProvider(model), prompt }),
278
+ gateway: () => generateText({ model: weflayrModel(weflayr, meta.model), headers: weflayr.headers(meta), prompt }),
279
+ direct: () => generateText({ model: yourProvider("claude-haiku-5-5"), prompt }),
202
280
  },
203
281
  );
204
282
  ```
@@ -210,9 +288,10 @@ saved.
210
288
  ### Group the calls of one job
211
289
 
212
290
  Pass the same `runId` (`newRunId()` makes one) to every call of one job. It is
213
- saved with each call and sent to Weflayr as the `run_id` tag.
214
- `weflayr.runCost(ctx, runId)` returns the cost of the whole job and of each
215
- step.
291
+ sent to Weflayr as the feature run id, so Weflayr prices the whole job, and it is
292
+ saved with each call. `weflayr.runCost(ctx, runId)` returns the cost of the whole
293
+ job and of each step. A run id is 32 lowercase hex characters, and anything else
294
+ is rejected.
216
295
 
217
296
  Weflayr also runs an MCP server at `https://mcp.weflayr.com/`, so an agent can
218
297
  read cost and margin as a tool.
@@ -221,12 +300,16 @@ read cost and margin as a tool.
221
300
 
222
301
  ```ts
223
302
  await weflayr.setBudget(ctx, "acme", 25); // 25 dollars a month, UTC calendar month
224
- await weflayr.assertWithinBudget(ctx, "acme"); // throws WeflayrBudgetError once spent
225
- const status = await weflayr.budget(ctx, "acme"); // { limitUsd, spentUsd, remainingUsd, allowed }
303
+ await weflayr.assertWithinBudget(ctx, "acme"); // throws once spent, or on a listed margin status
304
+ const status = await weflayr.budget(ctx, "acme"); // { limitUsd, spentUsd, remainingUsd, allowed, source, riskStatus }
226
305
  ```
227
306
 
228
- A customer without a budget always passes. Spend is the sum of saved cost for
229
- the month. A call already running when the limit is reached can still finish.
307
+ A customer without a budget always passes. After a sync, spend is Weflayr's real
308
+ cost for the month plus the estimated cost of calls made since shortly before the
309
+ sync (`settleMs`, five minutes by default). In that window a call can be counted
310
+ twice, so spend reads slightly high, never low. Before the first sync, spend is
311
+ the sum of your saved estimates and `source` is `"local"`. A call already running
312
+ when the limit is reached can still finish.
230
313
 
231
314
  ### Read the numbers
232
315
 
@@ -246,16 +329,16 @@ updates live in your UI.
246
329
 
247
330
  ## How cost is worked out
248
331
 
249
- The gateway does not send a price back with each response, so the component
250
- uses, in order:
332
+ Weflayr's dashboard is the source of truth for what you were billed, and `sync`
333
+ brings that figure into your database per customer and month. For each saved call
334
+ the gateway does not send a price back, so the component uses, in order:
251
335
 
252
336
  1. A cost you pass: `costUsd` in the call details, or from `extract` in `track`.
253
337
  Saved as `reported`.
254
338
  2. An estimate from your `prices` table. Saved as `estimated`.
255
339
  3. Zero, saved as `none`, when neither is available.
256
340
 
257
- Weflayr's dashboard is the source of truth for what you were billed. Use the
258
- saved cost to compare customers and enforce budgets, and fill `prices` from your
341
+ Per-call costs feed your lists and the gap between syncs. Fill `prices` from your
259
342
  provider's price list.
260
343
 
261
344
  ## What is saved
@@ -265,6 +348,7 @@ provider's price list.
265
348
  | `calls` | model call | until you run `weflayr.purge(ctx, before)` |
266
349
  | `daily` | customer, call and UTC day | always |
267
350
  | `budgets` | customer | until cleared |
351
+ | `synced` | customer and month | replaced by each sync |
268
352
 
269
353
  Queries read the `daily` rows, so they stay fast as `calls` grows. Days and
270
354
  months are UTC.
@@ -273,17 +357,19 @@ months are UTC.
273
357
 
274
358
  - Saving a call never fails the call. If the write fails, the component logs a
275
359
  warning and returns your answer.
276
- - The `plan`, `runId` and any `tags` you pass are sent to Weflayr as tags on the
277
- request.
360
+ - The `plan` and any `tags` you pass are sent to Weflayr as tags on the request,
361
+ and `runId` as its feature run id.
278
362
  - Customer names go to Weflayr as written. Use an id or a name you are happy to
279
363
  see in its dashboard.
280
364
 
281
365
  ## Limitations
282
366
 
283
- - Cost is an estimate from your `prices` table unless you pass the cost of a
284
- call.
285
- - A budget compares saved cost with the limit before a call starts, so a call
286
- already running can push a customer past it.
367
+ - Between syncs, cost is an estimate from your `prices` table unless you pass the
368
+ cost of a call.
369
+ - A budget compares spend with the limit before a call starts, so a call already
370
+ running can push a customer past it.
371
+ - Margin needs revenue in Weflayr, from Stripe, a CSV or `sendRevenue`. On an
372
+ internal project Weflayr has no margin, and `sync` saves cost only.
287
373
  - A customer who is not on the allow list runs the direct call, and that call is
288
374
  not saved.
289
375
  - `usageByCustomer` and the other reports read one `daily` row per customer,
@@ -301,8 +387,15 @@ months are UTC.
301
387
  **Cost shows $0.** The model is missing from `prices`. Add it, in dollars per
302
388
  million tokens.
303
389
 
304
- **A budget never stops anyone.** A budget compares saved cost, so it needs
305
- `prices`, or a `costUsd` on each call.
390
+ **A budget never stops anyone.** Run `sync` on a schedule, or fill `prices`, so
391
+ there is a cost to compare.
392
+
393
+ **`No customer for the call`.** The call has no signed-in user and no `customer`
394
+ function returned one. Pass the customer, or set `missingCustomer: "warn"`.
395
+
396
+ **`unknown_routing_strategy` or `invalid_routing_strategy_version`.** The
397
+ strategy id or version does not exist in the project of your key. List them in
398
+ the Weflayr dashboard.
306
399
 
307
400
  **The gateway returns 402.** Your Weflayr balance is empty. Add credit in the
308
401
  Weflayr dashboard.
@@ -0,0 +1,116 @@
1
+ export declare const MANAGEMENT_URL = "https://app.weflayr.com/api";
2
+ /** A customer's margin status in Weflayr. The thresholds behind each one are set in the Weflayr dashboard. */
3
+ export type RiskStatus = "healthy" | "watch" | "at_risk" | "unprofitable" | (string & {});
4
+ export type Aggregate = "day" | "week" | "month" | "quarter" | "year" | "all";
5
+ export type CostsInput = {
6
+ date_from: string;
7
+ date_to?: string;
8
+ aggregate?: Aggregate;
9
+ group_by?: "customer" | "feature" | "model" | "provider" | "call_name";
10
+ customers?: string[];
11
+ features?: string[];
12
+ call_names?: string[];
13
+ models?: string[];
14
+ providers?: string[];
15
+ customer_tags?: Record<string, string | string[]>;
16
+ run_tags?: Record<string, string | string[]>;
17
+ apply_credits?: boolean;
18
+ limit?: number;
19
+ cursor?: string;
20
+ };
21
+ export type CostRow = {
22
+ date_from: string;
23
+ date_to: string;
24
+ customer?: string;
25
+ feature?: string;
26
+ model?: string;
27
+ provider?: string;
28
+ call_name?: string;
29
+ cost: number;
30
+ avg_feature_run_cost?: number;
31
+ max_feature_run_cost?: number;
32
+ };
33
+ export type MarginInput = {
34
+ date_from: string;
35
+ date_to?: string;
36
+ aggregate?: Aggregate;
37
+ group_by?: "customer";
38
+ customers?: string[];
39
+ call_names?: string[];
40
+ filter_customers_based_on_features?: string[];
41
+ apply_credits?: boolean;
42
+ limit?: number;
43
+ cursor?: string;
44
+ };
45
+ export type MarginRow = {
46
+ date_from: string;
47
+ date_to: string;
48
+ customer?: string;
49
+ revenue: number;
50
+ cost: number;
51
+ margin: number;
52
+ margin_pct: number | null;
53
+ risk_status: RiskStatus;
54
+ };
55
+ export type Page<T> = {
56
+ rows: T[];
57
+ next_cursor: string | null;
58
+ has_more: boolean;
59
+ };
60
+ export type RevenueRow = {
61
+ /** The same customer name your model calls carry. */
62
+ customer_name: string;
63
+ amount: number;
64
+ /** "YYYY-MM". Or set month_start and month_end for a period longer than a month. */
65
+ month?: string;
66
+ month_start?: string;
67
+ month_end?: string;
68
+ /** An id from your billing system. With `upsert`, sending the same id again overwrites the row instead of adding to it. */
69
+ revenue_id?: string;
70
+ };
71
+ export type CustomerTags = {
72
+ customer_name: string;
73
+ /** A string sets the tag, null deletes it. Keys you leave out are not changed. */
74
+ tags: Record<string, string | null>;
75
+ };
76
+ export declare class WeflayrApiError extends Error {
77
+ readonly status: number;
78
+ constructor(status: number, message: string);
79
+ }
80
+ /** The first and last day of a "YYYY-MM" month. */
81
+ export declare function monthRange(month: string): {
82
+ from: string;
83
+ to: string;
84
+ };
85
+ /**
86
+ * A small client for Weflayr's management API: cost, margin, revenue and customer tags.
87
+ * It needs a key with the Management scope.
88
+ */
89
+ export declare class WeflayrApi {
90
+ private readonly options;
91
+ constructor(options?: {
92
+ apiKey?: string;
93
+ baseURL?: string;
94
+ });
95
+ private key;
96
+ private post;
97
+ getCosts(input: CostsInput): Promise<Page<CostRow>>;
98
+ getMargin(input: MarginInput): Promise<Page<MarginRow>>;
99
+ /** Books revenue per customer and month. Sends 1,000 rows per request. */
100
+ setRevenue(rows: RevenueRow[], options?: {
101
+ upsert?: boolean;
102
+ }): Promise<{
103
+ rowsInserted: number;
104
+ }>;
105
+ /** Sets tags such as the plan on customers. Sends 1,000 customers per request. */
106
+ setCustomerTags(customers: CustomerTags[], options?: {
107
+ upsert?: boolean;
108
+ }): Promise<{
109
+ customersUpdated: number;
110
+ }>;
111
+ /** What Weflayr has recorded for one customer in one month, in dollars, at list price. */
112
+ customerCost(customer: string, month: string): Promise<number>;
113
+ /** One customer's margin for one month, or null when Weflayr has no row for them. */
114
+ customerMargin(customer: string, month: string): Promise<MarginRow | null>;
115
+ }
116
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../../src/client/api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,130 @@
1
+ export const MANAGEMENT_URL = "https://app.weflayr.com/api";
2
+ export class WeflayrApiError extends Error {
3
+ status;
4
+ constructor(status, message) {
5
+ super(message);
6
+ this.status = status;
7
+ this.name = "WeflayrApiError";
8
+ }
9
+ }
10
+ const MAX_ROWS = 1000;
11
+ function chunk(items) {
12
+ const out = [];
13
+ for (let i = 0; i < items.length; i += MAX_ROWS)
14
+ out.push(items.slice(i, i + MAX_ROWS));
15
+ return out;
16
+ }
17
+ /** The first and last day of a "YYYY-MM" month. */
18
+ export function monthRange(month) {
19
+ const [year, monthIndex] = month.split("-").map(Number);
20
+ const last = new Date(Date.UTC(year, monthIndex, 0)).getUTCDate();
21
+ return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, "0")}` };
22
+ }
23
+ /**
24
+ * A small client for Weflayr's management API: cost, margin, revenue and customer tags.
25
+ * It needs a key with the Management scope.
26
+ */
27
+ export class WeflayrApi {
28
+ options;
29
+ constructor(options = {}) {
30
+ this.options = options;
31
+ }
32
+ key() {
33
+ const key = this.options.apiKey ??
34
+ process.env.WEFLAYR_MANAGEMENT_KEY ??
35
+ process.env.WEFLAYR_API_KEY;
36
+ if (!key) {
37
+ throw new Error("Set WEFLAYR_MANAGEMENT_KEY, or WEFLAYR_API_KEY with the Management scope.");
38
+ }
39
+ return key;
40
+ }
41
+ async post(path, body) {
42
+ let response;
43
+ try {
44
+ response = await fetch(`${this.options.baseURL ?? MANAGEMENT_URL}${path}`, {
45
+ method: "POST",
46
+ headers: {
47
+ authorization: `Bearer ${this.key()}`,
48
+ "content-type": "application/json",
49
+ },
50
+ body: JSON.stringify(body),
51
+ signal: AbortSignal.timeout(30_000),
52
+ });
53
+ }
54
+ catch (error) {
55
+ if (error instanceof Error && error.message.startsWith("Set WEFLAYR_"))
56
+ throw error;
57
+ const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error;
58
+ const code = cause.code;
59
+ throw new Error(`Could not reach Weflayr's management API (${this.options.baseURL ?? MANAGEMENT_URL}${path}): ${cause instanceof Error ? cause.message : String(cause)}${code ? ` [${code}]` : ""}`);
60
+ }
61
+ const text = await response.text();
62
+ if (!response.ok) {
63
+ let message = text.slice(0, 300);
64
+ try {
65
+ const parsed = JSON.parse(text);
66
+ message = String(parsed.error ?? JSON.stringify(parsed.detail ?? parsed)).slice(0, 300);
67
+ }
68
+ catch {
69
+ // keep the raw text
70
+ }
71
+ throw new WeflayrApiError(response.status, `Weflayr ${path} answered ${response.status}: ${message}`);
72
+ }
73
+ return JSON.parse(text);
74
+ }
75
+ getCosts(input) {
76
+ return this.post("/costs/", input);
77
+ }
78
+ getMargin(input) {
79
+ return this.post("/margin/", input);
80
+ }
81
+ /** Books revenue per customer and month. Sends 1,000 rows per request. */
82
+ async setRevenue(rows, options = {}) {
83
+ let inserted = 0;
84
+ for (const part of chunk(rows)) {
85
+ const result = await this.post("/revenue/", {
86
+ rows: part,
87
+ upsert: options.upsert ?? true,
88
+ });
89
+ inserted += result.rows_inserted ?? part.length;
90
+ }
91
+ return { rowsInserted: inserted };
92
+ }
93
+ /** Sets tags such as the plan on customers. Sends 1,000 customers per request. */
94
+ async setCustomerTags(customers, options = {}) {
95
+ let updated = 0;
96
+ for (const part of chunk(customers)) {
97
+ const result = await this.post("/customer-tags/", {
98
+ customers: part,
99
+ upsert: options.upsert ?? true,
100
+ });
101
+ updated += result.customers_updated ?? part.length;
102
+ }
103
+ return { customersUpdated: updated };
104
+ }
105
+ /** What Weflayr has recorded for one customer in one month, in dollars, at list price. */
106
+ async customerCost(customer, month) {
107
+ const { from, to } = monthRange(month);
108
+ const page = await this.getCosts({
109
+ date_from: from,
110
+ date_to: to,
111
+ aggregate: "all",
112
+ group_by: "customer",
113
+ customers: [customer],
114
+ });
115
+ return page.rows.reduce((sum, row) => sum + (row.cost ?? 0), 0);
116
+ }
117
+ /** One customer's margin for one month, or null when Weflayr has no row for them. */
118
+ async customerMargin(customer, month) {
119
+ const { from, to } = monthRange(month);
120
+ const page = await this.getMargin({
121
+ date_from: from,
122
+ date_to: to,
123
+ aggregate: "all",
124
+ group_by: "customer",
125
+ customers: [customer],
126
+ });
127
+ return page.rows[0] ?? null;
128
+ }
129
+ }
130
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../../src/client/api.ts"],"names":[],"mappings":"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"}