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,286 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { canonicalHash, sha256Hex } from "./canonical.js";
|
|
5
|
+
const id = (prefix) => z.string().regex(new RegExp(`^${prefix}_[A-Za-z0-9_]+$`), `must look like ${prefix}_...`);
|
|
6
|
+
export const SeedCustomerSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
id: id("cus"),
|
|
9
|
+
name: z.string(),
|
|
10
|
+
email: z.string().email().nullable().default(null),
|
|
11
|
+
description: z.string().nullable().default(null),
|
|
12
|
+
phone: z.string().nullable().default(null),
|
|
13
|
+
balance: z.number().int().default(0),
|
|
14
|
+
metadata: z.record(z.string()).default({}),
|
|
15
|
+
created_offset: z.number().int().min(0).default(0),
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
export const SeedProductSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
id: id("prod"),
|
|
21
|
+
name: z.string(),
|
|
22
|
+
description: z.string().nullable().default(null),
|
|
23
|
+
active: z.boolean().default(true),
|
|
24
|
+
metadata: z.record(z.string()).default({}),
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
export const SeedPriceSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
id: id("price"),
|
|
30
|
+
product: id("prod"),
|
|
31
|
+
unit_amount: z.number().int().min(0),
|
|
32
|
+
currency: z.string().length(3).default("usd"),
|
|
33
|
+
nickname: z.string().nullable().default(null),
|
|
34
|
+
recurring: z
|
|
35
|
+
.object({
|
|
36
|
+
interval: z.enum(["day", "week", "month", "year"]),
|
|
37
|
+
interval_count: z.number().int().min(1).default(1),
|
|
38
|
+
})
|
|
39
|
+
.strict()
|
|
40
|
+
.nullable()
|
|
41
|
+
.default(null),
|
|
42
|
+
active: z.boolean().default(true),
|
|
43
|
+
metadata: z.record(z.string()).default({}),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
export const SeedInvoiceLineSchema = z
|
|
47
|
+
.object({
|
|
48
|
+
description: z.string().nullable().default(null),
|
|
49
|
+
amount: z.number().int(),
|
|
50
|
+
quantity: z.number().int().min(1).default(1),
|
|
51
|
+
price: id("price").optional(),
|
|
52
|
+
})
|
|
53
|
+
.strict();
|
|
54
|
+
export const SeedChargeSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
id: id("ch"),
|
|
57
|
+
amount: z.number().int().min(1).optional(),
|
|
58
|
+
amount_refunded: z.number().int().min(0).optional(),
|
|
59
|
+
status: z.enum(["succeeded"]).default("succeeded"),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
|
62
|
+
export const SeedInvoiceSchema = z
|
|
63
|
+
.object({
|
|
64
|
+
id: id("in"),
|
|
65
|
+
customer: id("cus"),
|
|
66
|
+
currency: z.string().length(3).default("usd"),
|
|
67
|
+
status: z.enum(["draft", "open", "paid", "void", "uncollectible"]),
|
|
68
|
+
lines: z.array(SeedInvoiceLineSchema).min(1),
|
|
69
|
+
charges: z.array(SeedChargeSchema).default([]),
|
|
70
|
+
created_offset: z.number().int().min(0).default(0),
|
|
71
|
+
metadata: z.record(z.string()).default({}),
|
|
72
|
+
})
|
|
73
|
+
.strict();
|
|
74
|
+
export const SeedSubscriptionSchema = z
|
|
75
|
+
.object({
|
|
76
|
+
id: id("sub"),
|
|
77
|
+
customer: id("cus"),
|
|
78
|
+
price: id("price"),
|
|
79
|
+
quantity: z.number().int().min(1).default(1),
|
|
80
|
+
status: z.enum(["active"]).default("active"),
|
|
81
|
+
cancel_at_period_end: z.boolean().default(false),
|
|
82
|
+
current_period_start: z.number().int().optional(),
|
|
83
|
+
current_period_end: z.number().int().optional(),
|
|
84
|
+
created_offset: z.number().int().min(0).default(0),
|
|
85
|
+
metadata: z.record(z.string()).default({}),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
export const SeedTicketSchema = z
|
|
89
|
+
.object({
|
|
90
|
+
subject: z.string().min(1),
|
|
91
|
+
body: z.string().min(1),
|
|
92
|
+
status: z.enum(["new", "open", "pending", "hold", "solved", "closed"]).default("open"),
|
|
93
|
+
priority: z.enum(["low", "normal", "high", "urgent"]).default("normal"),
|
|
94
|
+
requester: z.object({ name: z.string().min(1), email: z.string().email() }).strict(),
|
|
95
|
+
tags: z.array(z.string()).default([]),
|
|
96
|
+
created_offset: z.number().int().min(0).default(0),
|
|
97
|
+
})
|
|
98
|
+
.strict();
|
|
99
|
+
export const SeedSchema = z
|
|
100
|
+
.object({
|
|
101
|
+
name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
102
|
+
version: z.number().int().min(1),
|
|
103
|
+
epoch: z.number().int().min(0),
|
|
104
|
+
clock_mode: z.enum(["manual", "auto"]).default("manual"),
|
|
105
|
+
auto_tick_seconds: z.number().int().min(1).default(1),
|
|
106
|
+
data: z
|
|
107
|
+
.object({
|
|
108
|
+
customers: z.array(SeedCustomerSchema).default([]),
|
|
109
|
+
products: z.array(SeedProductSchema).default([]),
|
|
110
|
+
prices: z.array(SeedPriceSchema).default([]),
|
|
111
|
+
invoices: z.array(SeedInvoiceSchema).default([]),
|
|
112
|
+
subscriptions: z.array(SeedSubscriptionSchema).default([]),
|
|
113
|
+
tickets: z.array(SeedTicketSchema).default([]),
|
|
114
|
+
})
|
|
115
|
+
.strict(),
|
|
116
|
+
})
|
|
117
|
+
.strict();
|
|
118
|
+
export class SeedValidationError extends Error {
|
|
119
|
+
issues;
|
|
120
|
+
constructor(issues) {
|
|
121
|
+
super(`Seed validation failed:\n${issues.map((i) => ` - ${i.path}: ${i.message}`).join("\n")}`);
|
|
122
|
+
this.issues = issues;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function parseSeed(doc) {
|
|
126
|
+
const parsed = SeedSchema.safeParse(doc);
|
|
127
|
+
if (!parsed.success) {
|
|
128
|
+
throw new SeedValidationError(parsed.error.issues.map((i) => ({ path: i.path.join(".") || "(root)", message: i.message })));
|
|
129
|
+
}
|
|
130
|
+
const seed = parsed.data;
|
|
131
|
+
const issues = [];
|
|
132
|
+
const dupCheck = (label, ids) => {
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
for (const i of ids) {
|
|
135
|
+
if (seen.has(i))
|
|
136
|
+
issues.push({ path: label, message: `duplicate id '${i}'` });
|
|
137
|
+
seen.add(i);
|
|
138
|
+
}
|
|
139
|
+
return seen;
|
|
140
|
+
};
|
|
141
|
+
const customers = dupCheck("data.customers", seed.data.customers.map((c) => c.id));
|
|
142
|
+
const products = dupCheck("data.products", seed.data.products.map((p) => p.id));
|
|
143
|
+
const prices = dupCheck("data.prices", seed.data.prices.map((p) => p.id));
|
|
144
|
+
dupCheck("data.invoices", seed.data.invoices.map((i) => i.id));
|
|
145
|
+
dupCheck("data.subscriptions", seed.data.subscriptions.map((s) => s.id));
|
|
146
|
+
dupCheck("data.invoices[].charges", seed.data.invoices.flatMap((i) => i.charges.map((c) => c.id)));
|
|
147
|
+
seed.data.prices.forEach((p, idx) => {
|
|
148
|
+
if (!products.has(p.product))
|
|
149
|
+
issues.push({ path: `data.prices[${idx}].product`, message: `unknown product '${p.product}'` });
|
|
150
|
+
});
|
|
151
|
+
seed.data.invoices.forEach((inv, idx) => {
|
|
152
|
+
if (!customers.has(inv.customer))
|
|
153
|
+
issues.push({ path: `data.invoices[${idx}].customer`, message: `unknown customer '${inv.customer}'` });
|
|
154
|
+
inv.lines.forEach((line, lidx) => {
|
|
155
|
+
if (line.price && !prices.has(line.price)) {
|
|
156
|
+
issues.push({ path: `data.invoices[${idx}].lines[${lidx}].price`, message: `unknown price '${line.price}'` });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (inv.charges.length > 0 && inv.status !== "paid") {
|
|
160
|
+
issues.push({
|
|
161
|
+
path: `data.invoices[${idx}].charges`,
|
|
162
|
+
message: `charges are only valid on paid invoices (status is '${inv.status}')`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const total = inv.lines.reduce((n, line) => n + line.amount, 0);
|
|
166
|
+
inv.charges.forEach((ch, cidx) => {
|
|
167
|
+
const cap = ch.amount ?? total;
|
|
168
|
+
if (ch.amount_refunded !== undefined && ch.amount_refunded > cap) {
|
|
169
|
+
issues.push({
|
|
170
|
+
path: `data.invoices[${idx}].charges[${cidx}].amount_refunded`,
|
|
171
|
+
message: `exceeds the charge amount (${cap})`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
seed.data.subscriptions.forEach((sub, idx) => {
|
|
177
|
+
if (!customers.has(sub.customer))
|
|
178
|
+
issues.push({ path: `data.subscriptions[${idx}].customer`, message: `unknown customer '${sub.customer}'` });
|
|
179
|
+
if (!prices.has(sub.price))
|
|
180
|
+
issues.push({ path: `data.subscriptions[${idx}].price`, message: `unknown price '${sub.price}'` });
|
|
181
|
+
const price = seed.data.prices.find((p) => p.id === sub.price);
|
|
182
|
+
if (price && !price.recurring)
|
|
183
|
+
issues.push({ path: `data.subscriptions[${idx}].price`, message: `price '${sub.price}' is not recurring` });
|
|
184
|
+
if (sub.current_period_start !== undefined &&
|
|
185
|
+
sub.current_period_end !== undefined &&
|
|
186
|
+
sub.current_period_end <= sub.current_period_start) {
|
|
187
|
+
issues.push({
|
|
188
|
+
path: `data.subscriptions[${idx}]`,
|
|
189
|
+
message: `current_period_end must be after current_period_start`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
if (issues.length > 0)
|
|
194
|
+
throw new SeedValidationError(issues);
|
|
195
|
+
return seed;
|
|
196
|
+
}
|
|
197
|
+
export function seedHash(seed) {
|
|
198
|
+
return canonicalHash(seed);
|
|
199
|
+
}
|
|
200
|
+
function deepFreeze(value) {
|
|
201
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
202
|
+
Object.freeze(value);
|
|
203
|
+
for (const key of Object.keys(value)) {
|
|
204
|
+
deepFreeze(value[key]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
export function createSeedLibrary(seedsDir) {
|
|
210
|
+
const byFile = new Map();
|
|
211
|
+
function readable(files) {
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const f of files) {
|
|
214
|
+
try {
|
|
215
|
+
const bytes = readFileSync(join(seedsDir, f));
|
|
216
|
+
out.push({ file: f, bytes, digest: sha256Hex(bytes) });
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
function parseEntry(file, bytes) {
|
|
224
|
+
try {
|
|
225
|
+
const seed = deepFreeze(parseSeed(JSON.parse(bytes.toString("utf8"))));
|
|
226
|
+
return { name: seed.name, file: join(seedsDir, file), seed, hash: seedHash(seed) };
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function load() {
|
|
233
|
+
const out = new Map();
|
|
234
|
+
if (!existsSync(seedsDir)) {
|
|
235
|
+
byFile.clear();
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
const files = readable(readdirSync(seedsDir)
|
|
239
|
+
.filter((f) => f.endsWith(".json"))
|
|
240
|
+
.sort());
|
|
241
|
+
const seen = new Set();
|
|
242
|
+
for (const { file, bytes, digest } of files) {
|
|
243
|
+
seen.add(file);
|
|
244
|
+
let cachedFile = byFile.get(file);
|
|
245
|
+
if (!cachedFile || cachedFile.digest !== digest) {
|
|
246
|
+
cachedFile = { digest, entry: parseEntry(file, bytes) };
|
|
247
|
+
byFile.set(file, cachedFile);
|
|
248
|
+
}
|
|
249
|
+
if (cachedFile.entry)
|
|
250
|
+
out.set(cachedFile.entry.name, cachedFile.entry);
|
|
251
|
+
}
|
|
252
|
+
for (const file of byFile.keys()) {
|
|
253
|
+
if (!seen.has(file))
|
|
254
|
+
byFile.delete(file);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
list: () => [...load().values()],
|
|
260
|
+
get: (name) => {
|
|
261
|
+
const entry = load().get(name);
|
|
262
|
+
return entry ? { seed: entry.seed, hash: entry.hash } : undefined;
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
export function createLayeredSeedLibrary(dirs) {
|
|
267
|
+
const layers = dirs.map((dir) => createSeedLibrary(dir));
|
|
268
|
+
return {
|
|
269
|
+
dirs,
|
|
270
|
+
list() {
|
|
271
|
+
const merged = new Map();
|
|
272
|
+
for (const layer of layers)
|
|
273
|
+
for (const entry of layer.list())
|
|
274
|
+
merged.set(entry.name, entry);
|
|
275
|
+
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
276
|
+
},
|
|
277
|
+
get(name) {
|
|
278
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
279
|
+
const hit = layers[i].get(name);
|
|
280
|
+
if (hit)
|
|
281
|
+
return hit;
|
|
282
|
+
}
|
|
283
|
+
return undefined;
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { parseSeed } from "./seed.js";
|
|
2
|
+
function offset(clock, created) {
|
|
3
|
+
return Math.max(0, clock - created);
|
|
4
|
+
}
|
|
5
|
+
const dayOfMonth = (unixSeconds) => new Date(unixSeconds * 1000).getUTCDate();
|
|
6
|
+
export function exportSeed(world, name) {
|
|
7
|
+
const clock = world.clock;
|
|
8
|
+
const byAge = (rows) => [...rows].sort((a, b) => a.created_seq - b.created_seq);
|
|
9
|
+
const lossy = {
|
|
10
|
+
refunds: world.listObjects("refunds").length,
|
|
11
|
+
payment_intents: world.listObjects("payment_intents").length,
|
|
12
|
+
invoiceitems_pending: 0,
|
|
13
|
+
standalone_charges: 0,
|
|
14
|
+
canceled_subscriptions: 0,
|
|
15
|
+
deleted_customers: world.db
|
|
16
|
+
.prepare("SELECT COUNT(*) AS n FROM objects WHERE resource = 'customers' AND deleted_seq IS NOT NULL")
|
|
17
|
+
.get().n,
|
|
18
|
+
events: world.listEvents().length,
|
|
19
|
+
notes: [],
|
|
20
|
+
};
|
|
21
|
+
const seedableEmail = (email) => email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null;
|
|
22
|
+
const customers = byAge(world.listObjects("customers")).map((o) => ({
|
|
23
|
+
id: o.data.id,
|
|
24
|
+
name: o.data.name ?? "(unnamed)",
|
|
25
|
+
email: seedableEmail(o.data.email),
|
|
26
|
+
description: o.data.description,
|
|
27
|
+
phone: o.data.phone,
|
|
28
|
+
balance: o.data.balance,
|
|
29
|
+
metadata: o.data.metadata,
|
|
30
|
+
created_offset: offset(clock, o.data.created),
|
|
31
|
+
}));
|
|
32
|
+
const products = byAge(world.listObjects("products")).map((o) => ({
|
|
33
|
+
id: o.data.id,
|
|
34
|
+
name: o.data.name,
|
|
35
|
+
description: o.data.description,
|
|
36
|
+
active: o.data.active,
|
|
37
|
+
metadata: o.data.metadata,
|
|
38
|
+
}));
|
|
39
|
+
const exportedPriceIds = new Set();
|
|
40
|
+
const prices = byAge(world.listObjects("prices")).map((o) => {
|
|
41
|
+
exportedPriceIds.add(o.data.id);
|
|
42
|
+
const recurring = o.data.recurring;
|
|
43
|
+
return {
|
|
44
|
+
id: o.data.id,
|
|
45
|
+
product: o.data.product,
|
|
46
|
+
unit_amount: o.data.unit_amount,
|
|
47
|
+
currency: o.data.currency,
|
|
48
|
+
nickname: o.data.nickname,
|
|
49
|
+
recurring: recurring ? { interval: recurring.interval, interval_count: recurring.interval_count } : null,
|
|
50
|
+
active: o.data.active,
|
|
51
|
+
metadata: o.data.metadata,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
const chargesByInvoice = new Map();
|
|
55
|
+
for (const o of byAge(world.listObjects("charges"))) {
|
|
56
|
+
if (o.data.status !== "succeeded")
|
|
57
|
+
continue;
|
|
58
|
+
const invoiceId = o.data.invoice;
|
|
59
|
+
if (!invoiceId) {
|
|
60
|
+
lossy.standalone_charges++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
let charges = chargesByInvoice.get(invoiceId);
|
|
64
|
+
if (!charges) {
|
|
65
|
+
charges = [];
|
|
66
|
+
chargesByInvoice.set(invoiceId, charges);
|
|
67
|
+
}
|
|
68
|
+
const refunded = o.data.amount_refunded ?? 0;
|
|
69
|
+
charges.push({
|
|
70
|
+
id: o.data.id,
|
|
71
|
+
amount: o.data.amount,
|
|
72
|
+
...(refunded > 0 ? { amount_refunded: refunded } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const invoices = byAge(world.listObjects("invoices")).map((o) => {
|
|
76
|
+
const lines = o.data.lines.data.map((line) => {
|
|
77
|
+
const price = line.price;
|
|
78
|
+
const priceId = price && exportedPriceIds.has(price.id) ? price.id : undefined;
|
|
79
|
+
return {
|
|
80
|
+
description: line.description ?? null,
|
|
81
|
+
amount: line.amount,
|
|
82
|
+
quantity: line.quantity ?? 1,
|
|
83
|
+
...(priceId ? { price: priceId } : {}),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const status = o.data.status;
|
|
87
|
+
return {
|
|
88
|
+
id: o.data.id,
|
|
89
|
+
customer: o.data.customer,
|
|
90
|
+
currency: o.data.currency,
|
|
91
|
+
status,
|
|
92
|
+
lines,
|
|
93
|
+
charges: status === "paid" ? (chargesByInvoice.get(o.data.id) ?? []) : [],
|
|
94
|
+
created_offset: offset(clock, o.data.created),
|
|
95
|
+
metadata: o.data.metadata,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
lossy.invoiceitems_pending = world.listObjects("invoiceitems").filter((o) => o.data.invoice === null).length;
|
|
99
|
+
let anchorsNotRepresentable = 0;
|
|
100
|
+
const subscriptions = byAge(world.listObjects("subscriptions"))
|
|
101
|
+
.filter((o) => {
|
|
102
|
+
if (o.data.status !== "active") {
|
|
103
|
+
lossy.canceled_subscriptions++;
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
})
|
|
108
|
+
.map((o) => {
|
|
109
|
+
const item = o.data.items.data[0];
|
|
110
|
+
const anchor = o.data.billing_cycle_anchor;
|
|
111
|
+
if (typeof anchor === "number" && dayOfMonth(anchor) !== dayOfMonth(o.data.current_period_start)) {
|
|
112
|
+
anchorsNotRepresentable++;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
id: o.data.id,
|
|
116
|
+
customer: o.data.customer,
|
|
117
|
+
price: item.price.id,
|
|
118
|
+
quantity: item.quantity,
|
|
119
|
+
cancel_at_period_end: o.data.cancel_at_period_end,
|
|
120
|
+
current_period_start: o.data.current_period_start,
|
|
121
|
+
current_period_end: o.data.current_period_end,
|
|
122
|
+
created_offset: offset(clock, o.data.created),
|
|
123
|
+
metadata: o.data.metadata,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
let ticketCommentsDropped = 0;
|
|
127
|
+
const tickets = byAge(world.listObjects("tickets")).map((o) => {
|
|
128
|
+
const comments = o.data.comments ?? [];
|
|
129
|
+
if (comments.length > 1)
|
|
130
|
+
ticketCommentsDropped += comments.length - 1;
|
|
131
|
+
const requester = o.data.requester;
|
|
132
|
+
return {
|
|
133
|
+
subject: o.data.subject,
|
|
134
|
+
body: comments[0]?.body ?? o.data.description,
|
|
135
|
+
status: o.data.status,
|
|
136
|
+
priority: o.data.priority,
|
|
137
|
+
requester: { name: requester.name, email: requester.email },
|
|
138
|
+
tags: o.data.tags,
|
|
139
|
+
created_offset: offset(clock, o.data.created),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
const noteIf = (n, what) => {
|
|
143
|
+
if (n > 0)
|
|
144
|
+
lossy.notes.push(`${n} ${what} not representable in a seed — dropped`);
|
|
145
|
+
};
|
|
146
|
+
if (lossy.refunds > 0) {
|
|
147
|
+
lossy.notes.push(`${lossy.refunds} refund(s) are not seedable; their totals survive as each charge's amount_refunded`);
|
|
148
|
+
}
|
|
149
|
+
noteIf(lossy.payment_intents, "payment intent(s)");
|
|
150
|
+
noteIf(lossy.standalone_charges, "charge(s) not attached to an invoice");
|
|
151
|
+
noteIf(lossy.invoiceitems_pending, "pending invoice item(s)");
|
|
152
|
+
noteIf(lossy.canceled_subscriptions, "canceled subscription(s)");
|
|
153
|
+
noteIf(lossy.deleted_customers, "deleted customer(s)");
|
|
154
|
+
noteIf(ticketCommentsDropped, "ticket reply comment(s) (seeds keep the opening message only)");
|
|
155
|
+
noteIf(anchorsNotRepresentable, "subscription billing anchor day(s) that differ from the current period start (a reimport re-derives the anchor from that period, so later renewal dates move)");
|
|
156
|
+
noteIf(world.listObjects("customer_balance_transactions").length, "customer balance transaction(s) (the resulting balance survives on the customer; the history does not)");
|
|
157
|
+
if (lossy.events > 0)
|
|
158
|
+
lossy.notes.push(`${lossy.events} event(s) are run history, not state — dropped`);
|
|
159
|
+
const seedDoc = {
|
|
160
|
+
name,
|
|
161
|
+
version: 1,
|
|
162
|
+
epoch: clock,
|
|
163
|
+
clock_mode: world.clockMode,
|
|
164
|
+
auto_tick_seconds: world.autoTickSeconds,
|
|
165
|
+
data: { customers, products, prices, invoices, subscriptions, tickets },
|
|
166
|
+
};
|
|
167
|
+
const seed = parseSeed(seedDoc);
|
|
168
|
+
return { seedDoc, seed, lossy };
|
|
169
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ACCOUNT_DEFAULT_CURRENCY, chargeShape, customerShape, invoicePrefixFor, invoiceShape, lineItemShape, priceShape, productShape, refundShape, subscriptionShape, ticketShape, } from "../twin/build.js";
|
|
2
|
+
import { stripeId } from "./prng.js";
|
|
3
|
+
import { addInterval } from "./time.js";
|
|
4
|
+
export function loadSeed(world, seed) {
|
|
5
|
+
const epoch = seed.epoch;
|
|
6
|
+
for (const p of seed.data.products) {
|
|
7
|
+
world.insertObject("products", productShape({
|
|
8
|
+
id: p.id,
|
|
9
|
+
created: epoch,
|
|
10
|
+
name: p.name,
|
|
11
|
+
description: p.description,
|
|
12
|
+
active: p.active,
|
|
13
|
+
metadata: p.metadata,
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
const priceObjects = new Map();
|
|
17
|
+
for (const p of seed.data.prices) {
|
|
18
|
+
const obj = priceShape({
|
|
19
|
+
id: p.id,
|
|
20
|
+
created: epoch,
|
|
21
|
+
product: p.product,
|
|
22
|
+
unit_amount: p.unit_amount,
|
|
23
|
+
currency: p.currency,
|
|
24
|
+
nickname: p.nickname,
|
|
25
|
+
active: p.active,
|
|
26
|
+
recurring: p.recurring ? { interval: p.recurring.interval, interval_count: p.recurring.interval_count } : null,
|
|
27
|
+
metadata: p.metadata,
|
|
28
|
+
});
|
|
29
|
+
priceObjects.set(p.id, obj);
|
|
30
|
+
world.insertObject("prices", obj);
|
|
31
|
+
}
|
|
32
|
+
for (const c of seed.data.customers) {
|
|
33
|
+
world.insertObject("customers", customerShape({
|
|
34
|
+
id: c.id,
|
|
35
|
+
created: epoch - c.created_offset,
|
|
36
|
+
name: c.name,
|
|
37
|
+
email: c.email,
|
|
38
|
+
description: c.description,
|
|
39
|
+
phone: c.phone,
|
|
40
|
+
balance: c.balance,
|
|
41
|
+
currency: c.balance === 0 ? null : ACCOUNT_DEFAULT_CURRENCY,
|
|
42
|
+
metadata: c.metadata,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
for (const inv of seed.data.invoices) {
|
|
46
|
+
const created = epoch - inv.created_offset;
|
|
47
|
+
const lines = inv.lines.map((line, idx) => lineItemShape({
|
|
48
|
+
id: `il_${inv.id.slice(3)}_${idx}`,
|
|
49
|
+
amount: line.amount * 1,
|
|
50
|
+
currency: inv.currency,
|
|
51
|
+
description: line.description,
|
|
52
|
+
period: { start: created, end: created },
|
|
53
|
+
price: line.price ? (priceObjects.get(line.price) ?? null) : null,
|
|
54
|
+
quantity: line.quantity,
|
|
55
|
+
type: "invoiceitem",
|
|
56
|
+
}));
|
|
57
|
+
const obj = invoiceShape({
|
|
58
|
+
id: inv.id,
|
|
59
|
+
created,
|
|
60
|
+
customer: inv.customer,
|
|
61
|
+
currency: inv.currency,
|
|
62
|
+
lines,
|
|
63
|
+
metadata: inv.metadata,
|
|
64
|
+
});
|
|
65
|
+
if (inv.status !== "draft") {
|
|
66
|
+
const seqInCustomer = world.incrCounter(`invnum:${inv.customer}`);
|
|
67
|
+
obj.number = `${invoicePrefixFor(inv.customer)}-${String(seqInCustomer).padStart(4, "0")}`;
|
|
68
|
+
obj.status = "open";
|
|
69
|
+
obj.status_transitions.finalized_at = created;
|
|
70
|
+
}
|
|
71
|
+
if (inv.status === "void") {
|
|
72
|
+
obj.status = "void";
|
|
73
|
+
obj.status_transitions.voided_at = created;
|
|
74
|
+
}
|
|
75
|
+
else if (inv.status === "uncollectible") {
|
|
76
|
+
obj.status = "uncollectible";
|
|
77
|
+
obj.status_transitions.marked_uncollectible_at = created;
|
|
78
|
+
obj.attempted = true;
|
|
79
|
+
obj.attempt_count = 1;
|
|
80
|
+
}
|
|
81
|
+
else if (inv.status === "paid") {
|
|
82
|
+
obj.status = "paid";
|
|
83
|
+
obj.paid = true;
|
|
84
|
+
obj.attempted = true;
|
|
85
|
+
obj.attempt_count = 1;
|
|
86
|
+
obj.amount_paid = obj.total;
|
|
87
|
+
obj.amount_remaining = 0;
|
|
88
|
+
obj.status_transitions.paid_at = created;
|
|
89
|
+
}
|
|
90
|
+
obj.charge = inv.charges[0]?.id ?? null;
|
|
91
|
+
world.insertObject("invoices", obj);
|
|
92
|
+
inv.charges.forEach((ch, idx) => {
|
|
93
|
+
const chargeObj = chargeShape({
|
|
94
|
+
id: ch.id,
|
|
95
|
+
created: created + idx,
|
|
96
|
+
amount: ch.amount ?? obj.total,
|
|
97
|
+
currency: inv.currency,
|
|
98
|
+
customer: inv.customer,
|
|
99
|
+
invoice: inv.id,
|
|
100
|
+
status: "succeeded",
|
|
101
|
+
description: `Payment for invoice ${obj.number ?? inv.id}`,
|
|
102
|
+
});
|
|
103
|
+
const refunded = ch.amount_refunded ?? 0;
|
|
104
|
+
if (refunded > 0) {
|
|
105
|
+
chargeObj.amount_refunded = refunded;
|
|
106
|
+
chargeObj.refunded = refunded === chargeObj.amount;
|
|
107
|
+
}
|
|
108
|
+
world.insertObject("charges", chargeObj);
|
|
109
|
+
if (refunded > 0)
|
|
110
|
+
world.insertObject("refunds", refundShape({
|
|
111
|
+
id: stripeId("re", `${world.seedHash}:seeded-refund:${ch.id}`, 0),
|
|
112
|
+
created: created + idx,
|
|
113
|
+
amount: refunded,
|
|
114
|
+
currency: inv.currency,
|
|
115
|
+
charge: ch.id,
|
|
116
|
+
payment_intent: null,
|
|
117
|
+
reason: null,
|
|
118
|
+
metadata: {},
|
|
119
|
+
}));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
for (const sub of seed.data.subscriptions) {
|
|
123
|
+
const created = epoch - sub.created_offset;
|
|
124
|
+
const priceDef = seed.data.prices.find((p) => p.id === sub.price);
|
|
125
|
+
const priceObj = priceObjects.get(sub.price);
|
|
126
|
+
const recurring = priceDef.recurring;
|
|
127
|
+
const periodStart = sub.current_period_start ?? created;
|
|
128
|
+
const periodEnd = sub.current_period_end ?? addInterval(periodStart, recurring.interval, recurring.interval_count);
|
|
129
|
+
world.insertObject("subscriptions", subscriptionShape({
|
|
130
|
+
id: sub.id,
|
|
131
|
+
created,
|
|
132
|
+
customer: sub.customer,
|
|
133
|
+
currency: priceDef.currency,
|
|
134
|
+
itemId: `si_${sub.id.slice(4)}`,
|
|
135
|
+
price: priceObj,
|
|
136
|
+
quantity: sub.quantity,
|
|
137
|
+
current_period_start: periodStart,
|
|
138
|
+
current_period_end: periodEnd,
|
|
139
|
+
cancel_at_period_end: sub.cancel_at_period_end,
|
|
140
|
+
metadata: sub.metadata,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
for (const t of seed.data.tickets) {
|
|
144
|
+
const ticket = ticketShape({
|
|
145
|
+
id: String(world.incrCounter("ticket_id")),
|
|
146
|
+
created: epoch - t.created_offset,
|
|
147
|
+
subject: t.subject,
|
|
148
|
+
description: t.body,
|
|
149
|
+
status: t.status,
|
|
150
|
+
priority: t.priority,
|
|
151
|
+
requester: t.requester,
|
|
152
|
+
tags: t.tags,
|
|
153
|
+
});
|
|
154
|
+
world.insertObject("tickets", ticket);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function addInterval(unixSeconds, interval, count, anchorDay) {
|
|
2
|
+
const d = new Date(unixSeconds * 1000);
|
|
3
|
+
switch (interval) {
|
|
4
|
+
case "day":
|
|
5
|
+
d.setUTCDate(d.getUTCDate() + count);
|
|
6
|
+
break;
|
|
7
|
+
case "week":
|
|
8
|
+
d.setUTCDate(d.getUTCDate() + 7 * count);
|
|
9
|
+
break;
|
|
10
|
+
case "month":
|
|
11
|
+
addMonths(d, count, anchorDay);
|
|
12
|
+
break;
|
|
13
|
+
case "year":
|
|
14
|
+
addMonths(d, 12 * count, anchorDay);
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
return Math.floor(d.getTime() / 1000);
|
|
18
|
+
}
|
|
19
|
+
function addMonths(d, months, anchorDay) {
|
|
20
|
+
const day = isDayOfMonth(anchorDay) ? anchorDay : d.getUTCDate();
|
|
21
|
+
d.setUTCDate(1);
|
|
22
|
+
d.setUTCMonth(d.getUTCMonth() + months);
|
|
23
|
+
const lastDayOfTarget = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
|
24
|
+
d.setUTCDate(Math.min(day, lastDayOfTarget));
|
|
25
|
+
}
|
|
26
|
+
function isDayOfMonth(day) {
|
|
27
|
+
return day !== undefined && Number.isInteger(day) && day >= 1 && day <= 31;
|
|
28
|
+
}
|
|
29
|
+
export function parseDuration(input) {
|
|
30
|
+
if (typeof input === "number") {
|
|
31
|
+
if (!Number.isFinite(input) || input <= 0)
|
|
32
|
+
throw new Error(`invalid duration: ${input}`);
|
|
33
|
+
return Math.floor(input);
|
|
34
|
+
}
|
|
35
|
+
const m = /^(\d+)\s*(s|m|h|d|w)?$/.exec(input.trim());
|
|
36
|
+
if (!m)
|
|
37
|
+
throw new Error(`invalid duration: '${input}' (expected e.g. 30d, 12h, 45m, 10s, or seconds)`);
|
|
38
|
+
const n = Number(m[1]);
|
|
39
|
+
const unit = m[2] ?? "s";
|
|
40
|
+
const mult = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[unit];
|
|
41
|
+
const seconds = n * mult;
|
|
42
|
+
if (seconds <= 0)
|
|
43
|
+
throw new Error(`invalid duration: '${input}' (must be greater than zero)`);
|
|
44
|
+
return seconds;
|
|
45
|
+
}
|