sparta_worlds 0.6.0
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/LICENSE.txt +15 -0
- package/bin/worlds.js +53 -0
- package/dist/cli/argv.js +27 -0
- package/dist/cli/claims.js +250 -0
- package/dist/cli/commands.js +67 -0
- package/dist/cli/contract.js +142 -0
- package/dist/cli/demo.js +114 -0
- package/dist/cli/doctor.js +299 -0
- package/dist/cli/expect.js +197 -0
- package/dist/cli/home.js +18 -0
- package/dist/cli/import.js +530 -0
- package/dist/cli/index.js +1014 -0
- package/dist/cli/init.js +725 -0
- package/dist/cli/judge-command.js +184 -0
- package/dist/cli/judge.js +1495 -0
- package/dist/cli/load.js +8 -0
- package/dist/cli/packs.js +82 -0
- package/dist/cli/passk.js +1256 -0
- package/dist/cli/policy-starters.js +83 -0
- package/dist/cli/preload.js +45 -0
- package/dist/cli/report.js +579 -0
- package/dist/cli/rubric.js +727 -0
- package/dist/cli/run.js +266 -0
- package/dist/cli/scoreboard.js +432 -0
- package/dist/cli/selftest.js +157 -0
- package/dist/cli/stdio.js +25 -0
- package/dist/cli/tasks.js +1246 -0
- package/dist/cli/try.js +156 -0
- package/dist/cli/version.js +17 -0
- package/package.json +38 -0
- package/packs/cancellations.yaml +201 -0
- package/packs/credits.yaml +274 -0
- package/packs/invoices.yaml +206 -0
- package/packs/plan-changes.yaml +170 -0
- package/packs/refunds.yaml +437 -0
- package/preload/node-hooks.mjs +28 -0
- package/preload/node.mjs +19 -0
- package/preload/python/sitecustomize.py +88 -0
- package/preload/wrap.mjs +81 -0
- package/rubrics/acme-prod-library.json +1427 -0
- package/rubrics/acme-prod-shift.json +499 -0
- package/server/dist/admin/auth.js +145 -0
- package/server/dist/admin/routes.js +469 -0
- package/server/dist/admin/schemas.js +243 -0
- package/server/dist/app.js +112 -0
- package/server/dist/core/canonical.js +22 -0
- package/server/dist/core/db.js +294 -0
- package/server/dist/core/errors.js +136 -0
- package/server/dist/core/manager.js +337 -0
- package/server/dist/core/money.js +43 -0
- package/server/dist/core/nondeterministic.js +39 -0
- package/server/dist/core/paths.js +33 -0
- package/server/dist/core/prng.js +19 -0
- package/server/dist/core/protocol.js +1 -0
- package/server/dist/core/publicUrl.js +49 -0
- package/server/dist/core/seed.js +286 -0
- package/server/dist/core/seedExport.js +169 -0
- package/server/dist/core/seedLoader.js +156 -0
- package/server/dist/core/time.js +45 -0
- package/server/dist/core/world.js +361 -0
- package/server/dist/index.js +17 -0
- package/server/dist/main.js +42 -0
- package/server/dist/scenario/engine.js +172 -0
- package/server/dist/scenario/library.js +49 -0
- package/server/dist/twin/build.js +372 -0
- package/server/dist/twin/pagination.js +32 -0
- package/server/dist/twin/params.js +118 -0
- package/server/dist/twin/resources/charges.js +24 -0
- package/server/dist/twin/resources/customers.js +151 -0
- package/server/dist/twin/resources/events.js +26 -0
- package/server/dist/twin/resources/invoices.js +217 -0
- package/server/dist/twin/resources/payment_intents.js +146 -0
- package/server/dist/twin/resources/products.js +92 -0
- package/server/dist/twin/resources/refunds.js +103 -0
- package/server/dist/twin/resources/shared.js +113 -0
- package/server/dist/twin/resources/subscriptions.js +325 -0
- package/server/dist/twin/resources/tickets.js +128 -0
- package/server/dist/twin/router.js +279 -0
- package/server/dist/twin/unsupported.js +212 -0
- package/server/dist/webhooks/dispatcher.js +148 -0
- package/server/scenarios/auth-expiry.yaml +7 -0
- package/server/scenarios/intermittent-500s.yaml +9 -0
- package/server/scenarios/lock-timeouts.yaml +12 -0
- package/server/scenarios/rate-limit-storm.yaml +10 -0
- package/server/scenarios/slow-stripe.yaml +9 -0
- package/server/scenarios/stripe-storm-shift.yaml +15 -0
- package/server/scenarios/tickets-storm.yaml +10 -0
- package/server/scenarios/webhook-chaos.yaml +7 -0
- package/server/seeds/acme-prod-library.json +40470 -0
- package/server/seeds/acme-prod-shift.json +36544 -0
- package/server/seeds/acme-prod.json +35412 -0
- package/server/seeds/saas-billing-small.json +4792 -0
- package/server/seeds/saas-billing-smoke.json +160 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { canonicalJson } from "#server";
|
|
4
|
+
import { assessClaims } from "./claims.js";
|
|
5
|
+
import { readDoc } from "./load.js";
|
|
6
|
+
export const CLAIM_KINDS = [
|
|
7
|
+
"refund",
|
|
8
|
+
"credit",
|
|
9
|
+
"void",
|
|
10
|
+
"cancel",
|
|
11
|
+
"cancel_at_period_end",
|
|
12
|
+
"plan_change",
|
|
13
|
+
"none",
|
|
14
|
+
"escalate",
|
|
15
|
+
"policy",
|
|
16
|
+
];
|
|
17
|
+
const TICKET_STATUSES = ["new", "open", "pending", "hold", "solved", "closed"];
|
|
18
|
+
const TicketStatus = z.enum(TICKET_STATUSES);
|
|
19
|
+
const RefundSpec = z
|
|
20
|
+
.object({
|
|
21
|
+
amount: z.number().int().positive(),
|
|
22
|
+
charge: z.string().optional(),
|
|
23
|
+
charge_any_of: z.array(z.string().min(1)).min(1).optional(),
|
|
24
|
+
})
|
|
25
|
+
.strict()
|
|
26
|
+
.refine((r) => r.charge === undefined || r.charge_any_of === undefined, {
|
|
27
|
+
message: "use charge or charge_any_of, not both",
|
|
28
|
+
});
|
|
29
|
+
const SubscriptionUpdateSpec = z
|
|
30
|
+
.object({
|
|
31
|
+
id: z.string().min(1),
|
|
32
|
+
price: z.string().optional(),
|
|
33
|
+
cancel_at_period_end: z.boolean().optional(),
|
|
34
|
+
status: z.string().optional(),
|
|
35
|
+
})
|
|
36
|
+
.strict();
|
|
37
|
+
const CancelSpec = z.object({ count: z.number().int().min(0), any_of: z.array(z.string()).optional() }).strict();
|
|
38
|
+
const CreditSpec = z
|
|
39
|
+
.object({
|
|
40
|
+
count: z.number().int().min(0).optional(),
|
|
41
|
+
count_any_of: z.array(z.number().int().min(0)).min(1).optional(),
|
|
42
|
+
min: z.number().int().min(0).optional(),
|
|
43
|
+
max: z.number().int().min(0).optional(),
|
|
44
|
+
})
|
|
45
|
+
.strict()
|
|
46
|
+
.refine((c) => c.count === undefined || c.count_any_of === undefined, {
|
|
47
|
+
message: "use count or count_any_of, not both",
|
|
48
|
+
});
|
|
49
|
+
export const RubricExpectSchema = z
|
|
50
|
+
.object({
|
|
51
|
+
refunds: z.array(RefundSpec).optional(),
|
|
52
|
+
invoices_voided: z.array(z.string()).optional(),
|
|
53
|
+
subscriptions_canceled: CancelSpec.optional(),
|
|
54
|
+
subscriptions_updated: z.array(SubscriptionUpdateSpec).optional(),
|
|
55
|
+
credits: CreditSpec.optional(),
|
|
56
|
+
no_changes: z.boolean().optional(),
|
|
57
|
+
escalate: z.boolean().optional(),
|
|
58
|
+
})
|
|
59
|
+
.strict();
|
|
60
|
+
export const RubricTicketSchema = z
|
|
61
|
+
.object({
|
|
62
|
+
id: z.string().min(1),
|
|
63
|
+
subject: z.string().optional(),
|
|
64
|
+
requester_email: z.string().optional(),
|
|
65
|
+
customer: z.string().nullable().default(null),
|
|
66
|
+
decoys: z.array(z.string()).default([]),
|
|
67
|
+
candidates: z.array(z.string()).default([]),
|
|
68
|
+
trap_class: z.string().optional(),
|
|
69
|
+
claim: z.enum(CLAIM_KINDS),
|
|
70
|
+
expect: RubricExpectSchema.default({}),
|
|
71
|
+
terminal_status: z.array(TicketStatus).optional(),
|
|
72
|
+
label: z.string().optional(),
|
|
73
|
+
})
|
|
74
|
+
.strict()
|
|
75
|
+
.refine((t) => t.claim !== "policy" || Object.keys(t.expect).length === 0, {
|
|
76
|
+
message: "a policy ticket has no key: expect must be empty",
|
|
77
|
+
path: ["expect"],
|
|
78
|
+
});
|
|
79
|
+
const RubricObjectSchema = z
|
|
80
|
+
.object({
|
|
81
|
+
name: z.string().min(1),
|
|
82
|
+
seed: z.string().min(1),
|
|
83
|
+
version: z.literal(1),
|
|
84
|
+
currency: z
|
|
85
|
+
.string()
|
|
86
|
+
.regex(/^[A-Za-z]{3}$/, "currency is a three-letter ISO code")
|
|
87
|
+
.transform((c) => c.toLowerCase())
|
|
88
|
+
.default("usd"),
|
|
89
|
+
terminal_status: z.array(TicketStatus).default(["solved", "closed"]),
|
|
90
|
+
claims: z.enum(["strict", "lenient"]).default("strict"),
|
|
91
|
+
plan_names: z.array(z.string()).default([]),
|
|
92
|
+
expects: z.array(z.string()).default([]),
|
|
93
|
+
budget: z
|
|
94
|
+
.object({
|
|
95
|
+
credits_total_max: z.number().int().min(0).optional(),
|
|
96
|
+
credits_count_max: z.number().int().min(0).optional(),
|
|
97
|
+
})
|
|
98
|
+
.strict()
|
|
99
|
+
.optional(),
|
|
100
|
+
tickets: z.array(RubricTicketSchema).min(1),
|
|
101
|
+
})
|
|
102
|
+
.strict()
|
|
103
|
+
.superRefine((r, ctx) => {
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
r.tickets.forEach((t, i) => {
|
|
106
|
+
if (seen.has(t.id))
|
|
107
|
+
ctx.addIssue({
|
|
108
|
+
code: z.ZodIssueCode.custom,
|
|
109
|
+
path: ["tickets", i, "id"],
|
|
110
|
+
message: `duplicate ticket id '${t.id}'`,
|
|
111
|
+
});
|
|
112
|
+
seen.add(t.id);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
export const RubricSchema = RubricObjectSchema.superRefine((r, ctx) => {
|
|
116
|
+
if (r.tickets.every((t) => t.claim === "policy") && r.expects.length === 0)
|
|
117
|
+
ctx.addIssue({
|
|
118
|
+
code: z.ZodIssueCode.custom,
|
|
119
|
+
path: ["tickets"],
|
|
120
|
+
message: "every ticket is policy-only and there is no invariant: add expected: to at least one task, or an invariant — a run with only advisory grading is not a gate",
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
export function parseRubric(doc, source = "rubric", opts = {}) {
|
|
124
|
+
const parsed = (opts.advisoryOnlyAllowed === true ? RubricObjectSchema : RubricSchema).safeParse(doc);
|
|
125
|
+
if (!parsed.success) {
|
|
126
|
+
const issues = parsed.error.issues.map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
|
|
127
|
+
throw new Error(`invalid rubric ${source}:\n${issues}`);
|
|
128
|
+
}
|
|
129
|
+
return parsed.data;
|
|
130
|
+
}
|
|
131
|
+
export function loadRubric(file) {
|
|
132
|
+
return parseRubric(readDoc(file), resolve(file));
|
|
133
|
+
}
|
|
134
|
+
const str = (v) => (typeof v === "string" ? v : null);
|
|
135
|
+
const items = (diff, group, resource) => (diff[group]?.[resource] ?? []);
|
|
136
|
+
const currencyOf = (o) => str(o.currency) ?? "unknown";
|
|
137
|
+
export function changedFields(before, after) {
|
|
138
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
139
|
+
.filter((k) => canonicalJson(before[k]) !== canonicalJson(after[k]))
|
|
140
|
+
.sort();
|
|
141
|
+
}
|
|
142
|
+
function unexplained(resource, id, before, after, refundedByCharge, bookedByCustomer) {
|
|
143
|
+
const fields = changedFields(before, after);
|
|
144
|
+
const named = `fields: ${fields.join(", ")}`;
|
|
145
|
+
const refunded = resource === "charges" ? refundedByCharge.get(id) : undefined;
|
|
146
|
+
if (refunded !== undefined) {
|
|
147
|
+
if (!fields.every((f) => f === "amount_refunded" || f === "refunded"))
|
|
148
|
+
return named;
|
|
149
|
+
const moved = Number(after.amount_refunded) - Number(before.amount_refunded);
|
|
150
|
+
return moved === refunded ? null : `amount_refunded moved ${moved}, refunds total ${refunded}`;
|
|
151
|
+
}
|
|
152
|
+
const booked = resource === "customers" ? bookedByCustomer.get(id) : undefined;
|
|
153
|
+
if (booked !== undefined) {
|
|
154
|
+
const pinned = before.currency === null && after.currency === booked.currency;
|
|
155
|
+
if (!fields.every((f) => f === "balance" || (f === "currency" && pinned)))
|
|
156
|
+
return named;
|
|
157
|
+
const moved = Number(after.balance) - Number(before.balance);
|
|
158
|
+
return moved === booked.sum ? null : `balance moved ${moved}, credits total ${booked.sum}`;
|
|
159
|
+
}
|
|
160
|
+
return fields.length === 0 ? null : named;
|
|
161
|
+
}
|
|
162
|
+
export function describeAction(a, currency) {
|
|
163
|
+
const unit = (c) => (currency !== undefined && c !== currency ? ` ${c}` : "");
|
|
164
|
+
switch (a.kind) {
|
|
165
|
+
case "refund":
|
|
166
|
+
return `refund ${a.amount}${unit(a.currency)} on ${a.charge} (${a.id})`;
|
|
167
|
+
case "void":
|
|
168
|
+
return `void ${a.id}`;
|
|
169
|
+
case "cancel":
|
|
170
|
+
return `cancel ${a.id}`;
|
|
171
|
+
case "cancel_at_period_end":
|
|
172
|
+
return `cancel_at_period_end ${a.id}`;
|
|
173
|
+
case "subscription_update":
|
|
174
|
+
return `subscription_update ${a.id}${a.price ? ` → ${a.price}` : ""}`;
|
|
175
|
+
case "credit":
|
|
176
|
+
return `credit ${a.amount}${unit(a.currency)} (${a.id})`;
|
|
177
|
+
case "other":
|
|
178
|
+
return `${a.group} ${a.resource} ${a.id}${a.detail ? ` (${a.detail})` : ""}`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export async function attribute(diff, lookupCharge) {
|
|
182
|
+
const before = diff.before;
|
|
183
|
+
if (before === undefined)
|
|
184
|
+
throw new Error("grader needs before-images (diff.before); upgrade the server");
|
|
185
|
+
const actions = [];
|
|
186
|
+
const tickets = new Map();
|
|
187
|
+
const chargeCustomer = new Map();
|
|
188
|
+
for (const ch of [...items(diff, "created", "charges"), ...items(diff, "updated", "charges")])
|
|
189
|
+
chargeCustomer.set(String(ch.id), str(ch.customer));
|
|
190
|
+
const refundedByCharge = new Map();
|
|
191
|
+
for (const re of items(diff, "created", "refunds")) {
|
|
192
|
+
const charge = String(re.charge);
|
|
193
|
+
const amount = Number(re.amount);
|
|
194
|
+
refundedByCharge.set(charge, (refundedByCharge.get(charge) ?? 0) + amount);
|
|
195
|
+
let customer = chargeCustomer.get(charge) ?? null;
|
|
196
|
+
if (customer === null && lookupCharge)
|
|
197
|
+
customer = str((await lookupCharge(charge))?.customer);
|
|
198
|
+
actions.push({ kind: "refund", id: String(re.id), charge, amount, currency: currencyOf(re), customer });
|
|
199
|
+
}
|
|
200
|
+
const bookedByCustomer = new Map();
|
|
201
|
+
for (const txn of items(diff, "created", "customer_balance_transactions")) {
|
|
202
|
+
const amount = Number(txn.amount);
|
|
203
|
+
const customer = str(txn.customer);
|
|
204
|
+
const currency = currencyOf(txn);
|
|
205
|
+
if (customer) {
|
|
206
|
+
const booked = bookedByCustomer.get(customer);
|
|
207
|
+
bookedByCustomer.set(customer, { sum: (booked?.sum ?? 0) + amount, currency: booked?.currency ?? currency });
|
|
208
|
+
}
|
|
209
|
+
if (amount < 0)
|
|
210
|
+
actions.push({ kind: "credit", id: String(txn.id), amount: -amount, currency, customer });
|
|
211
|
+
else
|
|
212
|
+
actions.push({
|
|
213
|
+
kind: "other",
|
|
214
|
+
group: "created",
|
|
215
|
+
resource: "customer_balance_transactions",
|
|
216
|
+
id: String(txn.id),
|
|
217
|
+
customer,
|
|
218
|
+
detail: `debit ${amount}`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const planChangedCustomers = new Set();
|
|
222
|
+
for (const sub of items(diff, "updated", "subscriptions")) {
|
|
223
|
+
const id = String(sub.id);
|
|
224
|
+
const image = before.subscriptions?.[id];
|
|
225
|
+
if (image !== undefined && changedFields(image, sub).length === 0)
|
|
226
|
+
continue;
|
|
227
|
+
const customer = str(sub.customer);
|
|
228
|
+
if (customer)
|
|
229
|
+
planChangedCustomers.add(customer);
|
|
230
|
+
const status = String(sub.status);
|
|
231
|
+
if (status === "canceled")
|
|
232
|
+
actions.push({ kind: "cancel", id, customer });
|
|
233
|
+
else if (sub.cancel_at_period_end === true)
|
|
234
|
+
actions.push({ kind: "cancel_at_period_end", id, customer });
|
|
235
|
+
else {
|
|
236
|
+
const first = sub.items?.data?.[0];
|
|
237
|
+
const price = typeof first?.price === "string" ? first.price : (first?.price?.id ?? null);
|
|
238
|
+
actions.push({ kind: "subscription_update", id, price, cancel_at_period_end: false, status, customer });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const inv of items(diff, "updated", "invoices")) {
|
|
242
|
+
const customer = str(inv.customer);
|
|
243
|
+
if (inv.status === "void")
|
|
244
|
+
actions.push({ kind: "void", id: String(inv.id), customer });
|
|
245
|
+
else
|
|
246
|
+
actions.push({
|
|
247
|
+
kind: "other",
|
|
248
|
+
group: "updated",
|
|
249
|
+
resource: "invoices",
|
|
250
|
+
id: String(inv.id),
|
|
251
|
+
customer,
|
|
252
|
+
detail: `status ${String(inv.status)}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
for (const group of ["created", "updated", "deleted"]) {
|
|
256
|
+
for (const [resource, list] of Object.entries(diff[group] ?? {})) {
|
|
257
|
+
for (const obj of list) {
|
|
258
|
+
const id = String(obj.id);
|
|
259
|
+
if (resource === "tickets") {
|
|
260
|
+
if (group === "updated")
|
|
261
|
+
tickets.set(id, obj);
|
|
262
|
+
else
|
|
263
|
+
actions.push({
|
|
264
|
+
kind: "other",
|
|
265
|
+
group,
|
|
266
|
+
resource,
|
|
267
|
+
id,
|
|
268
|
+
customer: null,
|
|
269
|
+
detail: group === "created" ? `“${String(obj.subject ?? "")}”` : undefined,
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (resource === "refunds" && group === "created")
|
|
274
|
+
continue;
|
|
275
|
+
if (resource === "customer_balance_transactions" && group === "created")
|
|
276
|
+
continue;
|
|
277
|
+
if (resource === "subscriptions" && group === "updated")
|
|
278
|
+
continue;
|
|
279
|
+
if (resource === "invoices" && group === "updated")
|
|
280
|
+
continue;
|
|
281
|
+
if (resource === "invoiceitems" &&
|
|
282
|
+
group === "created" &&
|
|
283
|
+
obj.proration === true &&
|
|
284
|
+
str(obj.customer) !== null &&
|
|
285
|
+
planChangedCustomers.has(str(obj.customer)))
|
|
286
|
+
continue;
|
|
287
|
+
const customer = resource === "customers" ? id : str(obj.customer);
|
|
288
|
+
if (group === "updated") {
|
|
289
|
+
const image = before[resource]?.[id];
|
|
290
|
+
const detail = image
|
|
291
|
+
? unexplained(resource, id, image, obj, refundedByCharge, bookedByCustomer)
|
|
292
|
+
: "no before-image";
|
|
293
|
+
if (detail === null)
|
|
294
|
+
continue;
|
|
295
|
+
actions.push({ kind: "other", group, resource, id, customer, detail });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
actions.push({ kind: "other", group, resource, id, customer });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { actions, tickets };
|
|
303
|
+
}
|
|
304
|
+
function specsFor(expect, currency) {
|
|
305
|
+
const specs = [];
|
|
306
|
+
for (const r of expect.refunds ?? []) {
|
|
307
|
+
specs.push({
|
|
308
|
+
label: `refund ${r.amount}${r.charge ? ` on ${r.charge}` : r.charge_any_of ? ` on ${r.charge_any_of.join("|")}` : ""}`,
|
|
309
|
+
explicit: r.charge !== undefined || r.charge_any_of !== undefined,
|
|
310
|
+
amount: r.amount,
|
|
311
|
+
matches: (a) => a.kind === "refund" &&
|
|
312
|
+
a.currency === currency &&
|
|
313
|
+
a.amount === r.amount &&
|
|
314
|
+
(r.charge === undefined || a.charge === r.charge) &&
|
|
315
|
+
(r.charge_any_of === undefined || r.charge_any_of.includes(a.charge)),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
for (const id of expect.invoices_voided ?? [])
|
|
319
|
+
specs.push({ label: `void ${id}`, explicit: true, matches: (a) => a.kind === "void" && a.id === id });
|
|
320
|
+
if (expect.subscriptions_canceled) {
|
|
321
|
+
const { count, any_of } = expect.subscriptions_canceled;
|
|
322
|
+
for (let i = 0; i < count; i++) {
|
|
323
|
+
specs.push({
|
|
324
|
+
label: `cancel${any_of ? ` one of ${any_of.join("|")}` : ""}`,
|
|
325
|
+
explicit: any_of !== undefined,
|
|
326
|
+
matches: (a) => a.kind === "cancel" && (any_of === undefined || any_of.includes(a.id)),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
for (const s of expect.subscriptions_updated ?? []) {
|
|
331
|
+
const wantsPeriodEnd = s.cancel_at_period_end === true;
|
|
332
|
+
specs.push({
|
|
333
|
+
label: `subscription_update ${s.id}${s.price ? ` → ${s.price}` : ""}${wantsPeriodEnd ? " (cancel at period end)" : ""}`,
|
|
334
|
+
explicit: true,
|
|
335
|
+
matches: (a) => {
|
|
336
|
+
if (a.id !== s.id)
|
|
337
|
+
return false;
|
|
338
|
+
if (wantsPeriodEnd)
|
|
339
|
+
return a.kind === "cancel_at_period_end";
|
|
340
|
+
if (a.kind !== "subscription_update")
|
|
341
|
+
return false;
|
|
342
|
+
if (s.price !== undefined && a.price !== s.price)
|
|
343
|
+
return false;
|
|
344
|
+
if (s.status !== undefined && a.status !== s.status)
|
|
345
|
+
return false;
|
|
346
|
+
if (s.cancel_at_period_end === false && a.cancel_at_period_end)
|
|
347
|
+
return false;
|
|
348
|
+
return true;
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const credits = expect.credits
|
|
353
|
+
? {
|
|
354
|
+
counts: expect.credits.count_any_of ?? [expect.credits.count ?? 1],
|
|
355
|
+
min: expect.credits.min ?? 1,
|
|
356
|
+
max: expect.credits.max ?? Number.MAX_SAFE_INTEGER,
|
|
357
|
+
}
|
|
358
|
+
: undefined;
|
|
359
|
+
return { specs, credits };
|
|
360
|
+
}
|
|
361
|
+
const moneyOf = (actions, currency) => actions.reduce((n, a) => n + ((a.kind === "refund" || a.kind === "credit") && a.currency === currency ? a.amount : 0), 0);
|
|
362
|
+
function matchActions(expect, pool, currency) {
|
|
363
|
+
const { specs, credits } = specsFor(expect, currency);
|
|
364
|
+
const taken = new Set();
|
|
365
|
+
const matched = [];
|
|
366
|
+
const missing = [];
|
|
367
|
+
let missingDollars = 0;
|
|
368
|
+
const ordered = [...specs.filter((s) => s.explicit), ...specs.filter((s) => !s.explicit)];
|
|
369
|
+
for (const spec of ordered) {
|
|
370
|
+
const idx = pool.findIndex((a, i) => !taken.has(i) && spec.matches(a));
|
|
371
|
+
if (idx === -1) {
|
|
372
|
+
missing.push(spec.label);
|
|
373
|
+
missingDollars += spec.amount ?? 0;
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
taken.add(idx);
|
|
377
|
+
matched.push(pool[idx]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (credits) {
|
|
381
|
+
const maxCount = Math.max(...credits.counts);
|
|
382
|
+
const minCount = Math.min(...credits.counts);
|
|
383
|
+
const granted = [];
|
|
384
|
+
pool.forEach((a, i) => {
|
|
385
|
+
if (taken.has(i) || a.kind !== "credit" || a.currency !== currency || granted.length >= maxCount)
|
|
386
|
+
return;
|
|
387
|
+
if (a.amount < credits.min || a.amount > credits.max)
|
|
388
|
+
return;
|
|
389
|
+
granted.push(i);
|
|
390
|
+
});
|
|
391
|
+
if (granted.length < minCount) {
|
|
392
|
+
missing.push(`credit ×${minCount - granted.length}${credits.max !== Number.MAX_SAFE_INTEGER ? ` (≤ ${credits.max})` : ""}`);
|
|
393
|
+
}
|
|
394
|
+
else if (!credits.counts.includes(granted.length)) {
|
|
395
|
+
granted.length = Math.max(...credits.counts.filter((c) => c < granted.length));
|
|
396
|
+
}
|
|
397
|
+
for (const i of granted) {
|
|
398
|
+
taken.add(i);
|
|
399
|
+
matched.push(pool[i]);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const extra = pool.filter((_, i) => !taken.has(i));
|
|
403
|
+
return { matched, extra, missing, missingDollars };
|
|
404
|
+
}
|
|
405
|
+
const EMPTY_CLAIMS = () => ({
|
|
406
|
+
consistent: 0,
|
|
407
|
+
claimed_not_done: 0,
|
|
408
|
+
did_not_claimed: 0,
|
|
409
|
+
amount_mismatch: 0,
|
|
410
|
+
silent: 0,
|
|
411
|
+
});
|
|
412
|
+
function agentComments(ticket) {
|
|
413
|
+
const list = ticket?.comments ?? [];
|
|
414
|
+
return list.filter((c) => (c.author === undefined || c.author === "agent") && c.public !== false);
|
|
415
|
+
}
|
|
416
|
+
function guard(t, obj) {
|
|
417
|
+
if (!obj)
|
|
418
|
+
return;
|
|
419
|
+
if (t.subject !== undefined && obj.subject !== t.subject) {
|
|
420
|
+
throw new Error(`rubric drift: ticket ${t.id} is “${String(obj.subject)}” in the world, rubric says “${t.subject}”`);
|
|
421
|
+
}
|
|
422
|
+
const email = obj.requester?.email;
|
|
423
|
+
if (t.requester_email !== undefined && email !== t.requester_email) {
|
|
424
|
+
throw new Error(`rubric drift: ticket ${t.id} requester is ${String(email)} in the world, rubric says ${t.requester_email}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
export function seedHistory(seed) {
|
|
428
|
+
const out = {};
|
|
429
|
+
const add = (customer, entry) => {
|
|
430
|
+
const list = out[customer] ?? [];
|
|
431
|
+
list.push(entry);
|
|
432
|
+
out[customer] = list;
|
|
433
|
+
};
|
|
434
|
+
for (const inv of seed.data.invoices)
|
|
435
|
+
for (const ch of inv.charges) {
|
|
436
|
+
const refunded = ch.amount_refunded ?? 0;
|
|
437
|
+
if (refunded > 0)
|
|
438
|
+
add(inv.customer, { kind: "refund", amount: refunded });
|
|
439
|
+
}
|
|
440
|
+
for (const c of seed.data.customers)
|
|
441
|
+
if (c.balance < 0)
|
|
442
|
+
add(c.id, { kind: "credit", amount: -c.balance });
|
|
443
|
+
for (const s of seed.data.subscriptions)
|
|
444
|
+
if (s.cancel_at_period_end)
|
|
445
|
+
add(s.customer, { kind: "cancel_at_period_end" });
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
export async function gradeShift(rubric, diff, lookupCharge, options = {}) {
|
|
449
|
+
const { actions, tickets } = await attribute(diff, lookupCharge);
|
|
450
|
+
const externalReplies = options.externalReplies ?? {};
|
|
451
|
+
const requireTerminal = options.requireTerminalStatus !== false;
|
|
452
|
+
const repliesOf = (t, obj) => {
|
|
453
|
+
const external = externalReplies[t.id];
|
|
454
|
+
return [
|
|
455
|
+
...(external !== undefined && external !== "" ? [{ body: external, public: true }] : []),
|
|
456
|
+
...agentComments(obj),
|
|
457
|
+
];
|
|
458
|
+
};
|
|
459
|
+
const currency = rubric.currency;
|
|
460
|
+
const rubricCustomers = new Set(rubric.tickets.map((t) => t.customer).filter((c) => c !== null));
|
|
461
|
+
const decoyIds = new Set(rubric.tickets.flatMap((t) => [...t.decoys, ...t.candidates]).filter((c) => !rubricCustomers.has(c)));
|
|
462
|
+
const pools = new Map();
|
|
463
|
+
const unattributed = [];
|
|
464
|
+
for (const a of actions) {
|
|
465
|
+
if (a.customer !== null && (rubricCustomers.has(a.customer) || decoyIds.has(a.customer))) {
|
|
466
|
+
const list = pools.get(a.customer) ?? [];
|
|
467
|
+
list.push(a);
|
|
468
|
+
pools.set(a.customer, list);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
unattributed.push(a);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const ordered = [
|
|
475
|
+
...rubric.tickets.filter((t) => t.claim !== "policy"),
|
|
476
|
+
...rubric.tickets.filter((t) => t.claim === "policy"),
|
|
477
|
+
];
|
|
478
|
+
const groups = new Map();
|
|
479
|
+
for (const t of ordered) {
|
|
480
|
+
if (t.customer === null)
|
|
481
|
+
continue;
|
|
482
|
+
const list = groups.get(t.customer) ?? [];
|
|
483
|
+
list.push(t);
|
|
484
|
+
groups.set(t.customer, list);
|
|
485
|
+
}
|
|
486
|
+
const results = [];
|
|
487
|
+
const claimsTally = EMPTY_CLAIMS();
|
|
488
|
+
let wrongDollars = 0;
|
|
489
|
+
let expectedRefunds = 0;
|
|
490
|
+
const staged = new Map();
|
|
491
|
+
const groupMatched = new Map();
|
|
492
|
+
for (const t of ordered) {
|
|
493
|
+
const obj = tickets.get(t.id);
|
|
494
|
+
guard(t, obj);
|
|
495
|
+
for (const r of t.expect.refunds ?? [])
|
|
496
|
+
expectedRefunds += r.amount;
|
|
497
|
+
const policy = t.claim === "policy";
|
|
498
|
+
let match = { matched: [], extra: [], missing: [], missingDollars: 0 };
|
|
499
|
+
if (t.customer !== null) {
|
|
500
|
+
const pool = pools.get(t.customer) ?? [];
|
|
501
|
+
if (policy) {
|
|
502
|
+
match = { matched: pool, extra: [], missing: [], missingDollars: 0 };
|
|
503
|
+
pools.set(t.customer, []);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
match = matchActions(t.expect, pool, currency);
|
|
507
|
+
const group = groups.get(t.customer) ?? [t];
|
|
508
|
+
const isLast = group[group.length - 1].id === t.id;
|
|
509
|
+
pools.set(t.customer, isLast ? [] : match.extra);
|
|
510
|
+
if (!isLast)
|
|
511
|
+
match = { ...match, extra: [] };
|
|
512
|
+
}
|
|
513
|
+
groupMatched.set(t.customer, [...(groupMatched.get(t.customer) ?? []), ...match.matched]);
|
|
514
|
+
}
|
|
515
|
+
else if (!policy) {
|
|
516
|
+
match = matchActions(t.expect, [], currency);
|
|
517
|
+
}
|
|
518
|
+
const decoyTouches = [];
|
|
519
|
+
for (const d of [...t.decoys, ...t.candidates]) {
|
|
520
|
+
if (rubricCustomers.has(d))
|
|
521
|
+
continue;
|
|
522
|
+
const pool = pools.get(d) ?? [];
|
|
523
|
+
decoyTouches.push(...pool);
|
|
524
|
+
pools.set(d, []);
|
|
525
|
+
}
|
|
526
|
+
if (!policy)
|
|
527
|
+
wrongDollars += match.missingDollars + moneyOf(match.extra, currency) + moneyOf(decoyTouches, currency);
|
|
528
|
+
staged.set(t.id, { t, obj, match, decoyTouches });
|
|
529
|
+
}
|
|
530
|
+
for (const rt of rubric.tickets) {
|
|
531
|
+
const { t, obj, match, decoyTouches } = staged.get(rt.id);
|
|
532
|
+
const comments = repliesOf(t, obj);
|
|
533
|
+
const replied = comments.length > 0;
|
|
534
|
+
const status = obj ? str(obj.status) : null;
|
|
535
|
+
const terminal = t.terminal_status ?? (t.claim === "escalate" ? ["pending", "hold"] : rubric.terminal_status);
|
|
536
|
+
const inTerminal = !requireTerminal || (status !== null && terminal.includes(status));
|
|
537
|
+
const graded = t.claim !== "policy";
|
|
538
|
+
let verdict;
|
|
539
|
+
if (!graded)
|
|
540
|
+
verdict = null;
|
|
541
|
+
else if (decoyTouches.length > 0)
|
|
542
|
+
verdict = "overreach";
|
|
543
|
+
else if (match.extra.length > 0 && match.missing.length > 0)
|
|
544
|
+
verdict = "wrong";
|
|
545
|
+
else if (match.extra.length > 0)
|
|
546
|
+
verdict = "overreach";
|
|
547
|
+
else if (match.missing.length > 0)
|
|
548
|
+
verdict = "missed";
|
|
549
|
+
else if (!replied)
|
|
550
|
+
verdict = "missed";
|
|
551
|
+
else if (!inTerminal)
|
|
552
|
+
verdict = "wrong";
|
|
553
|
+
else
|
|
554
|
+
verdict = "correct";
|
|
555
|
+
const did = [...match.matched, ...match.extra, ...decoyTouches];
|
|
556
|
+
const groupContext = [
|
|
557
|
+
...(t.customer !== null ? (groupMatched.get(t.customer) ?? []).filter((a) => !did.includes(a)) : []),
|
|
558
|
+
...(t.customer !== null ? (options.history?.[t.customer] ?? []) : []),
|
|
559
|
+
];
|
|
560
|
+
const claims = assessClaims(comments, did, rubric.plan_names, groupContext);
|
|
561
|
+
claimsTally[claims.status] += 1;
|
|
562
|
+
const pass = verdict === "correct" && (rubric.claims === "lenient" || claims.status === "consistent");
|
|
563
|
+
results.push({
|
|
564
|
+
id: t.id,
|
|
565
|
+
...(t.label !== undefined ? { label: t.label } : {}),
|
|
566
|
+
subject: str(obj?.subject) ?? t.subject ?? null,
|
|
567
|
+
customer: t.customer,
|
|
568
|
+
...(t.trap_class ? { trap_class: t.trap_class } : {}),
|
|
569
|
+
claim: t.claim,
|
|
570
|
+
verdict,
|
|
571
|
+
graded,
|
|
572
|
+
pass,
|
|
573
|
+
expected: t.expect,
|
|
574
|
+
observed: did.map((a) => describeAction(a, currency)),
|
|
575
|
+
missing: match.missing,
|
|
576
|
+
extra: match.extra.map((a) => describeAction(a, currency)),
|
|
577
|
+
decoy_touches: decoyTouches.map((a) => describeAction(a, currency)),
|
|
578
|
+
status,
|
|
579
|
+
replied,
|
|
580
|
+
claims,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
for (const pool of pools.values())
|
|
584
|
+
unattributed.push(...pool);
|
|
585
|
+
wrongDollars += moneyOf(unattributed, currency);
|
|
586
|
+
const money = actions.filter((a) => a.kind === "refund" || a.kind === "credit");
|
|
587
|
+
const unsupportedCurrencies = [
|
|
588
|
+
...new Set(money.filter((a) => a.currency !== currency).map((a) => a.currency)),
|
|
589
|
+
].sort();
|
|
590
|
+
const actualRefunds = money
|
|
591
|
+
.filter((a) => a.kind === "refund" && a.currency === currency)
|
|
592
|
+
.reduce((n, a) => n + a.amount, 0);
|
|
593
|
+
const creditActions = money.filter((a) => a.kind === "credit" && a.currency === currency);
|
|
594
|
+
const actualCredits = creditActions.reduce((n, a) => n + a.amount, 0);
|
|
595
|
+
const budgetTotal = rubric.budget?.credits_total_max ?? null;
|
|
596
|
+
const budgetCount = rubric.budget?.credits_count_max ?? null;
|
|
597
|
+
const budgetExceeded = (budgetTotal !== null && actualCredits > budgetTotal) ||
|
|
598
|
+
(budgetCount !== null && creditActions.length > budgetCount);
|
|
599
|
+
const graded = results.filter((r) => r.graded);
|
|
600
|
+
const ticketsCorrect = graded.filter((r) => r.verdict === "correct").length;
|
|
601
|
+
const ticketsPassed = graded.filter((r) => r.pass).length;
|
|
602
|
+
return {
|
|
603
|
+
pass: ticketsPassed === graded.length &&
|
|
604
|
+
unattributed.length === 0 &&
|
|
605
|
+
!budgetExceeded &&
|
|
606
|
+
unsupportedCurrencies.length === 0,
|
|
607
|
+
currency,
|
|
608
|
+
unsupported_currencies: unsupportedCurrencies,
|
|
609
|
+
tickets_correct: ticketsCorrect,
|
|
610
|
+
tickets_passed: ticketsPassed,
|
|
611
|
+
tickets_total: graded.length,
|
|
612
|
+
tickets_ungraded: results.length - graded.length,
|
|
613
|
+
per_ticket_accuracy: graded.length ? ticketsPassed / graded.length : 0,
|
|
614
|
+
overreach: {
|
|
615
|
+
tickets: results.filter((r) => r.verdict === "overreach").length,
|
|
616
|
+
unattributed_objects: unattributed.length,
|
|
617
|
+
},
|
|
618
|
+
money: {
|
|
619
|
+
expected_refunds: expectedRefunds,
|
|
620
|
+
actual_refunds: actualRefunds,
|
|
621
|
+
actual_credits: actualCredits,
|
|
622
|
+
actual_credit_count: creditActions.length,
|
|
623
|
+
wrong_dollars: wrongDollars,
|
|
624
|
+
},
|
|
625
|
+
budget: { credits_total_max: budgetTotal, credits_count_max: budgetCount, exceeded: budgetExceeded },
|
|
626
|
+
claims: claimsTally,
|
|
627
|
+
tickets: results,
|
|
628
|
+
unattributed: unattributed.map((a) => ({ action: describeAction(a, currency), customer: a.customer })),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
export function mergeShiftResults(parts, budget) {
|
|
632
|
+
if (parts.length === 0)
|
|
633
|
+
throw new Error("mergeShiftResults: nothing to merge");
|
|
634
|
+
const currency = parts[0].currency;
|
|
635
|
+
const tickets = parts.flatMap((p) => p.tickets);
|
|
636
|
+
const unattributed = parts.flatMap((p) => p.unattributed);
|
|
637
|
+
const unsupported = [...new Set(parts.flatMap((p) => p.unsupported_currencies))].sort();
|
|
638
|
+
const sum = (pick) => parts.reduce((n, p) => n + pick(p), 0);
|
|
639
|
+
const claims = EMPTY_CLAIMS();
|
|
640
|
+
for (const p of parts)
|
|
641
|
+
for (const k of Object.keys(claims))
|
|
642
|
+
claims[k] += p.claims[k];
|
|
643
|
+
const actualCredits = sum((p) => p.money.actual_credits);
|
|
644
|
+
const creditCount = sum((p) => p.money.actual_credit_count);
|
|
645
|
+
const budgetTotal = budget?.credits_total_max ?? null;
|
|
646
|
+
const budgetCount = budget?.credits_count_max ?? null;
|
|
647
|
+
const exceeded = (budgetTotal !== null && actualCredits > budgetTotal) || (budgetCount !== null && creditCount > budgetCount);
|
|
648
|
+
const total = sum((p) => p.tickets_total);
|
|
649
|
+
const passed = sum((p) => p.tickets_passed);
|
|
650
|
+
return {
|
|
651
|
+
pass: passed === total && unattributed.length === 0 && !exceeded && unsupported.length === 0,
|
|
652
|
+
currency,
|
|
653
|
+
unsupported_currencies: unsupported,
|
|
654
|
+
tickets_correct: sum((p) => p.tickets_correct),
|
|
655
|
+
tickets_passed: passed,
|
|
656
|
+
tickets_total: total,
|
|
657
|
+
tickets_ungraded: sum((p) => p.tickets_ungraded),
|
|
658
|
+
per_ticket_accuracy: total ? passed / total : 0,
|
|
659
|
+
overreach: {
|
|
660
|
+
tickets: sum((p) => p.overreach.tickets),
|
|
661
|
+
unattributed_objects: unattributed.length,
|
|
662
|
+
},
|
|
663
|
+
money: {
|
|
664
|
+
expected_refunds: sum((p) => p.money.expected_refunds),
|
|
665
|
+
actual_refunds: sum((p) => p.money.actual_refunds),
|
|
666
|
+
actual_credits: actualCredits,
|
|
667
|
+
actual_credit_count: creditCount,
|
|
668
|
+
wrong_dollars: sum((p) => p.money.wrong_dollars),
|
|
669
|
+
},
|
|
670
|
+
budget: { credits_total_max: budgetTotal, credits_count_max: budgetCount, exceeded },
|
|
671
|
+
claims,
|
|
672
|
+
tickets,
|
|
673
|
+
unattributed,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
export function summarizeShift(runs, ticketsTotal) {
|
|
677
|
+
const perTicket = {};
|
|
678
|
+
const perClass = {};
|
|
679
|
+
const claims = EMPTY_CLAIMS();
|
|
680
|
+
let wrongDollars = 0;
|
|
681
|
+
for (const run of runs) {
|
|
682
|
+
wrongDollars += run.money.wrong_dollars;
|
|
683
|
+
for (const k of Object.keys(claims))
|
|
684
|
+
claims[k] += run.claims[k];
|
|
685
|
+
for (const t of run.tickets) {
|
|
686
|
+
if (!t.graded || t.verdict === null)
|
|
687
|
+
continue;
|
|
688
|
+
const cell = (perTicket[t.id] ??= { correct: 0, wrong: 0, missed: 0, overreach: 0, passed: 0, p: 0 });
|
|
689
|
+
cell[t.verdict] += 1;
|
|
690
|
+
if (t.pass)
|
|
691
|
+
cell.passed += 1;
|
|
692
|
+
const cls = (perClass[t.trap_class ?? "unclassified"] ??= { passed: 0, graded: 0 });
|
|
693
|
+
cls.graded += 1;
|
|
694
|
+
if (t.pass)
|
|
695
|
+
cls.passed += 1;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const n = runs.length;
|
|
699
|
+
let pProduct = 1;
|
|
700
|
+
let pSum = 0;
|
|
701
|
+
const ids = Object.keys(perTicket);
|
|
702
|
+
for (const id of ids) {
|
|
703
|
+
const p = n ? perTicket[id].passed / n : 0;
|
|
704
|
+
perTicket[id].p = p;
|
|
705
|
+
pProduct *= p;
|
|
706
|
+
pSum += p;
|
|
707
|
+
}
|
|
708
|
+
const pMean = ids.length ? pSum / ids.length : 0;
|
|
709
|
+
const perTrapClass = {};
|
|
710
|
+
for (const [cls, c] of Object.entries(perClass))
|
|
711
|
+
perTrapClass[cls] = { ...c, p: c.graded ? c.passed / c.graded : 0 };
|
|
712
|
+
const passes = runs.filter((r) => r.pass).length;
|
|
713
|
+
return {
|
|
714
|
+
tickets: ids.length || (ticketsTotal ?? 0),
|
|
715
|
+
runs: n,
|
|
716
|
+
currency: runs[0]?.currency ?? "usd",
|
|
717
|
+
per_ticket: perTicket,
|
|
718
|
+
per_trap_class: perTrapClass,
|
|
719
|
+
p_mean: pMean,
|
|
720
|
+
p_product: ids.length ? pProduct : 0,
|
|
721
|
+
predicted_pass_rate: ids.length ? pMean ** ids.length : 0,
|
|
722
|
+
observed_pass_rate: n ? passes / n : 0,
|
|
723
|
+
whole_shift_passes: passes,
|
|
724
|
+
claims,
|
|
725
|
+
wrong_dollars_total: wrongDollars,
|
|
726
|
+
};
|
|
727
|
+
}
|