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,530 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const SEED_FIELDS = {
|
|
3
|
+
customers: ["id", "name", "email", "balance", "created_offset", "metadata"],
|
|
4
|
+
products: ["id", "name", "active"],
|
|
5
|
+
prices: ["id", "product", "unit_amount", "currency", "nickname", "active", "recurring"],
|
|
6
|
+
invoices: ["id", "customer", "currency", "status", "created_offset", "lines", "charges"],
|
|
7
|
+
lines: ["description", "amount", "quantity", "price"],
|
|
8
|
+
charges: ["id", "amount", "amount_refunded"],
|
|
9
|
+
subscriptions: [
|
|
10
|
+
"id",
|
|
11
|
+
"customer",
|
|
12
|
+
"price",
|
|
13
|
+
"quantity",
|
|
14
|
+
"cancel_at_period_end",
|
|
15
|
+
"created_offset",
|
|
16
|
+
"current_period_start",
|
|
17
|
+
"current_period_end",
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
export const PSEUDONYMIZATION_NOTE = "Identities are replaced by deterministic fakes and ids are hashed and remapped: pseudonymization, not anonymity. Amounts, cadence and timing survive and can identify an account on their own; free text is replaced unless a --keep-* flag says otherwise. Product and price ids are copied verbatim because agent code references them; a merchant-chosen product or price id is free text and is listed under verbatim.";
|
|
21
|
+
export function identityMapDoc(seedName, seedHashValue, identities) {
|
|
22
|
+
return { version: 1, seed: seedName, seed_hash: seedHashValue, customers: identities };
|
|
23
|
+
}
|
|
24
|
+
const FIRST = [
|
|
25
|
+
"Avery",
|
|
26
|
+
"Blair",
|
|
27
|
+
"Casey",
|
|
28
|
+
"Devon",
|
|
29
|
+
"Ellis",
|
|
30
|
+
"Frankie",
|
|
31
|
+
"Gray",
|
|
32
|
+
"Harper",
|
|
33
|
+
"Indigo",
|
|
34
|
+
"Jules",
|
|
35
|
+
"Kai",
|
|
36
|
+
"Lane",
|
|
37
|
+
"Marlowe",
|
|
38
|
+
"Noa",
|
|
39
|
+
"Oakley",
|
|
40
|
+
"Parker",
|
|
41
|
+
"Quinn",
|
|
42
|
+
"Reese",
|
|
43
|
+
"Sage",
|
|
44
|
+
"Tatum",
|
|
45
|
+
"Umber",
|
|
46
|
+
"Vale",
|
|
47
|
+
"Wren",
|
|
48
|
+
"Xen",
|
|
49
|
+
"Yael",
|
|
50
|
+
"Zephyr",
|
|
51
|
+
];
|
|
52
|
+
const LAST = [
|
|
53
|
+
"Ashford",
|
|
54
|
+
"Birch",
|
|
55
|
+
"Calder",
|
|
56
|
+
"Dunmore",
|
|
57
|
+
"Eastwood",
|
|
58
|
+
"Fenwick",
|
|
59
|
+
"Garland",
|
|
60
|
+
"Hollis",
|
|
61
|
+
"Ingram",
|
|
62
|
+
"Jasper",
|
|
63
|
+
"Kessler",
|
|
64
|
+
"Lockhart",
|
|
65
|
+
"Merritt",
|
|
66
|
+
"Northway",
|
|
67
|
+
"Oakes",
|
|
68
|
+
"Pemberton",
|
|
69
|
+
"Quill",
|
|
70
|
+
"Rutledge",
|
|
71
|
+
"Sterling",
|
|
72
|
+
"Thorne",
|
|
73
|
+
"Underhill",
|
|
74
|
+
"Vance",
|
|
75
|
+
"Whitfield",
|
|
76
|
+
"Xanders",
|
|
77
|
+
"York",
|
|
78
|
+
"Zale",
|
|
79
|
+
];
|
|
80
|
+
const B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
81
|
+
function digest(input) {
|
|
82
|
+
return createHash("sha256").update(`worlds-import:${input}`).digest();
|
|
83
|
+
}
|
|
84
|
+
function remapId(prefix, originalId) {
|
|
85
|
+
const d = digest(originalId);
|
|
86
|
+
let out = "";
|
|
87
|
+
for (let i = 0; i < 11; i++)
|
|
88
|
+
out += B62[d[i] % 62];
|
|
89
|
+
return `${prefix}_imp${out}`;
|
|
90
|
+
}
|
|
91
|
+
function fakeIdentity(originalId) {
|
|
92
|
+
const d = digest(`identity:${originalId}`);
|
|
93
|
+
const first = FIRST[d[0] % FIRST.length];
|
|
94
|
+
const last = LAST[d[1] % LAST.length];
|
|
95
|
+
let tag = "";
|
|
96
|
+
for (let i = 2; i < 6; i++)
|
|
97
|
+
tag += B62[d[i] % 62].toLowerCase();
|
|
98
|
+
return { name: `${first} ${last}`, email: `${first.toLowerCase()}.${last.toLowerCase()}.${tag}@example.com` };
|
|
99
|
+
}
|
|
100
|
+
function tag(input) {
|
|
101
|
+
const d = digest(`label:${input}`);
|
|
102
|
+
let out = "";
|
|
103
|
+
for (let i = 0; i < 4; i++)
|
|
104
|
+
out += B62[d[i] % 62];
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
const fakeProductName = (productId) => `Product ${tag(productId)}`;
|
|
108
|
+
const fakePriceNickname = (price) => `${price.recurring ? "Plan" : "Item"} ${tag(String(price.id))}`;
|
|
109
|
+
const GENERATED_ID_LENGTH = { prod: 14, price: 24 };
|
|
110
|
+
const isGeneratedId = (prefix, id) => new RegExp(`^${prefix}_[A-Za-z0-9]{${GENERATED_ID_LENGTH[prefix]}}$`).test(id);
|
|
111
|
+
const hasMetadata = (o) => o.metadata !== null && typeof o.metadata === "object" && Object.keys(o.metadata).length > 0;
|
|
112
|
+
async function stripeGet(opts, path, params = {}) {
|
|
113
|
+
const qs = new URLSearchParams(params).toString();
|
|
114
|
+
const res = await fetch(`${opts.baseUrl}${path}${qs ? `?${qs}` : ""}`, {
|
|
115
|
+
headers: { authorization: `Bearer ${opts.apiKey}` },
|
|
116
|
+
});
|
|
117
|
+
const text = await res.text();
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
let message = text;
|
|
120
|
+
try {
|
|
121
|
+
message = JSON.parse(text).error?.message ?? text;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`GET ${path} → ${res.status}: ${message}`);
|
|
126
|
+
}
|
|
127
|
+
return JSON.parse(text);
|
|
128
|
+
}
|
|
129
|
+
async function listAll(opts, path, max, params = {}) {
|
|
130
|
+
const out = [];
|
|
131
|
+
let startingAfter;
|
|
132
|
+
while (out.length < max) {
|
|
133
|
+
const page = await stripeGet(opts, path, {
|
|
134
|
+
...params,
|
|
135
|
+
limit: String(Math.min(100, max - out.length)),
|
|
136
|
+
...(startingAfter ? { starting_after: startingAfter } : {}),
|
|
137
|
+
});
|
|
138
|
+
const data = page.data ?? [];
|
|
139
|
+
out.push(...data);
|
|
140
|
+
if (!page.has_more || data.length === 0)
|
|
141
|
+
break;
|
|
142
|
+
startingAfter = String(data[data.length - 1].id);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
const num = (v, fallback = 0) => (typeof v === "number" ? v : fallback);
|
|
147
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
148
|
+
export async function importStripeSeed(opts) {
|
|
149
|
+
const skipped = [];
|
|
150
|
+
const products = await listAll(opts, "/v1/products", opts.limit);
|
|
151
|
+
const prices = await listAll(opts, "/v1/prices", opts.limit);
|
|
152
|
+
const customers = (await listAll(opts, "/v1/customers", opts.limit)).filter((c) => c.deleted !== true);
|
|
153
|
+
const invoices = await listAll(opts, "/v1/invoices", opts.limit);
|
|
154
|
+
const charges = await listAll(opts, "/v1/charges", opts.limit);
|
|
155
|
+
const subscriptions = await listAll(opts, "/v1/subscriptions", opts.limit, { status: "active" });
|
|
156
|
+
const priceById = new Map(prices.map((p) => [String(p.id), p]));
|
|
157
|
+
const productIds = new Set(products.map((p) => String(p.id)));
|
|
158
|
+
const customerIds = new Set(customers.map((c) => String(c.id)));
|
|
159
|
+
const named = { emails: 0, found: 0, missing: [] };
|
|
160
|
+
if (opts.customerEmails !== undefined && opts.customerEmails.length > 0) {
|
|
161
|
+
const invoiceIds = new Set(invoices.map((i) => String(i.id)));
|
|
162
|
+
const chargeIds = new Set(charges.map((c) => String(c.id)));
|
|
163
|
+
const subscriptionIds = new Set(subscriptions.map((s) => String(s.id)));
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
const live = (list) => list.filter((c) => c.deleted !== true);
|
|
166
|
+
for (const raw of opts.customerEmails) {
|
|
167
|
+
const email = raw.trim();
|
|
168
|
+
const key = email.toLowerCase();
|
|
169
|
+
if (email === "" || seen.has(key))
|
|
170
|
+
continue;
|
|
171
|
+
seen.add(key);
|
|
172
|
+
named.emails++;
|
|
173
|
+
let found = live(await listAll(opts, "/v1/customers", 100, { email }));
|
|
174
|
+
if (found.length === 0 && key !== email)
|
|
175
|
+
found = live(await listAll(opts, "/v1/customers", 100, { email: key }));
|
|
176
|
+
if (found.length === 0) {
|
|
177
|
+
named.missing.push(email);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
named.found++;
|
|
181
|
+
for (const c of found) {
|
|
182
|
+
const id = String(c.id);
|
|
183
|
+
if (!customerIds.has(id)) {
|
|
184
|
+
customers.push(c);
|
|
185
|
+
customerIds.add(id);
|
|
186
|
+
}
|
|
187
|
+
for (const inv of await listAll(opts, "/v1/invoices", opts.limit, { customer: id }))
|
|
188
|
+
if (!invoiceIds.has(String(inv.id))) {
|
|
189
|
+
invoices.push(inv);
|
|
190
|
+
invoiceIds.add(String(inv.id));
|
|
191
|
+
}
|
|
192
|
+
for (const ch of await listAll(opts, "/v1/charges", opts.limit, { customer: id }))
|
|
193
|
+
if (!chargeIds.has(String(ch.id))) {
|
|
194
|
+
charges.push(ch);
|
|
195
|
+
chargeIds.add(String(ch.id));
|
|
196
|
+
}
|
|
197
|
+
for (const sub of await listAll(opts, "/v1/subscriptions", opts.limit, { customer: id, status: "active" }))
|
|
198
|
+
if (!subscriptionIds.has(String(sub.id))) {
|
|
199
|
+
subscriptions.push(sub);
|
|
200
|
+
subscriptionIds.add(String(sub.id));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const wantCustomer = async (id) => {
|
|
206
|
+
if (!id)
|
|
207
|
+
return false;
|
|
208
|
+
if (customerIds.has(id))
|
|
209
|
+
return true;
|
|
210
|
+
try {
|
|
211
|
+
const c = await stripeGet(opts, `/v1/customers/${id}`);
|
|
212
|
+
if (c.deleted === true)
|
|
213
|
+
return false;
|
|
214
|
+
customers.push(c);
|
|
215
|
+
customerIds.add(id);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const wantPrice = async (id) => {
|
|
223
|
+
if (!id)
|
|
224
|
+
return undefined;
|
|
225
|
+
const cached = priceById.get(id);
|
|
226
|
+
if (cached)
|
|
227
|
+
return cached;
|
|
228
|
+
try {
|
|
229
|
+
const p = await stripeGet(opts, `/v1/prices/${id}`);
|
|
230
|
+
prices.push(p);
|
|
231
|
+
priceById.set(id, p);
|
|
232
|
+
return p;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
for (const sub of subscriptions) {
|
|
239
|
+
await wantCustomer(str(sub.customer));
|
|
240
|
+
const item = sub.items?.data?.[0];
|
|
241
|
+
await wantPrice(str(item?.price?.id));
|
|
242
|
+
}
|
|
243
|
+
for (const inv of invoices)
|
|
244
|
+
await wantCustomer(str(inv.customer));
|
|
245
|
+
for (const price of prices) {
|
|
246
|
+
const productId = str(price.product);
|
|
247
|
+
if (productId && !productIds.has(productId)) {
|
|
248
|
+
try {
|
|
249
|
+
products.push(await stripeGet(opts, `/v1/products/${productId}`));
|
|
250
|
+
productIds.add(productId);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
priceById.delete(String(price.id));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const allCreated = [...customers, ...products, ...prices, ...invoices, ...charges, ...subscriptions]
|
|
258
|
+
.map((o) => num(o.created))
|
|
259
|
+
.filter((n) => n > 0);
|
|
260
|
+
if (allCreated.length === 0 && opts.epoch === undefined) {
|
|
261
|
+
throw new Error("nothing to import: the account returned no objects (use --epoch to force an empty seed)");
|
|
262
|
+
}
|
|
263
|
+
const epoch = opts.epoch ?? Math.ceil(Math.max(...allCreated) / 86400) * 86400;
|
|
264
|
+
const offset = (created) => Math.max(0, epoch - num(created, epoch));
|
|
265
|
+
const chargesByInvoice = new Map();
|
|
266
|
+
for (const ch of charges) {
|
|
267
|
+
const invoiceId = str(ch.invoice);
|
|
268
|
+
if (!invoiceId || ch.status !== "succeeded" || ch.paid !== true)
|
|
269
|
+
continue;
|
|
270
|
+
const group = chargesByInvoice.get(invoiceId) ?? [];
|
|
271
|
+
group.push(ch);
|
|
272
|
+
chargesByInvoice.set(invoiceId, group);
|
|
273
|
+
}
|
|
274
|
+
const keepMetadata = opts.keepMetadata === true;
|
|
275
|
+
const manifest = {
|
|
276
|
+
scrub: {
|
|
277
|
+
replaced: {
|
|
278
|
+
customer_names: 0,
|
|
279
|
+
customer_emails: 0,
|
|
280
|
+
product_names: 0,
|
|
281
|
+
price_nicknames: 0,
|
|
282
|
+
line_descriptions: 0,
|
|
283
|
+
line_descriptions_from_kept_nickname: 0,
|
|
284
|
+
},
|
|
285
|
+
dropped: { customer_descriptions: 0, customer_phones: 0, customer_metadata: 0, other_metadata: 0 },
|
|
286
|
+
retained: {
|
|
287
|
+
product_names: opts.keepNames === true,
|
|
288
|
+
price_nicknames: opts.keepNames === true,
|
|
289
|
+
line_descriptions: opts.keepDescriptions === true,
|
|
290
|
+
metadata: keepMetadata,
|
|
291
|
+
},
|
|
292
|
+
samples: {},
|
|
293
|
+
verbatim: { product_ids: { count: 0, samples: [] }, price_ids: { count: 0, samples: [] } },
|
|
294
|
+
allowlist: SEED_FIELDS,
|
|
295
|
+
note: PSEUDONYMIZATION_NOTE,
|
|
296
|
+
},
|
|
297
|
+
lossy: { refunds_folded: 0, disputed_charges: [], notes: [] },
|
|
298
|
+
};
|
|
299
|
+
const sample = (kind, text) => {
|
|
300
|
+
const list = manifest.scrub.samples[kind] ?? [];
|
|
301
|
+
if (list.length < 3)
|
|
302
|
+
list.push(text);
|
|
303
|
+
manifest.scrub.samples[kind] = list;
|
|
304
|
+
};
|
|
305
|
+
const verbatimId = (prefix, id) => {
|
|
306
|
+
if (isGeneratedId(prefix, id))
|
|
307
|
+
return;
|
|
308
|
+
const entry = manifest.scrub.verbatim[prefix === "prod" ? "product_ids" : "price_ids"];
|
|
309
|
+
entry.count++;
|
|
310
|
+
if (entry.samples.length < 3)
|
|
311
|
+
entry.samples.push(id);
|
|
312
|
+
};
|
|
313
|
+
const dropOtherMetadata = (o) => {
|
|
314
|
+
if (hasMetadata(o))
|
|
315
|
+
manifest.scrub.dropped.other_metadata++;
|
|
316
|
+
};
|
|
317
|
+
const identities = [];
|
|
318
|
+
const seedCustomers = customers.map((c) => {
|
|
319
|
+
const id = String(c.id);
|
|
320
|
+
const fake = fakeIdentity(id);
|
|
321
|
+
identities.push({
|
|
322
|
+
id,
|
|
323
|
+
email: str(c.email) ?? null,
|
|
324
|
+
name: str(c.name) ?? null,
|
|
325
|
+
pseudonym: { id: remapId("cus", id), email: fake.email, name: fake.name },
|
|
326
|
+
});
|
|
327
|
+
if (str(c.name))
|
|
328
|
+
manifest.scrub.replaced.customer_names++;
|
|
329
|
+
if (str(c.email))
|
|
330
|
+
manifest.scrub.replaced.customer_emails++;
|
|
331
|
+
if (str(c.description))
|
|
332
|
+
manifest.scrub.dropped.customer_descriptions++;
|
|
333
|
+
if (str(c.phone))
|
|
334
|
+
manifest.scrub.dropped.customer_phones++;
|
|
335
|
+
const metadata = hasMetadata(c);
|
|
336
|
+
if (metadata && !keepMetadata)
|
|
337
|
+
manifest.scrub.dropped.customer_metadata++;
|
|
338
|
+
return {
|
|
339
|
+
id: remapId("cus", id),
|
|
340
|
+
name: fake.name,
|
|
341
|
+
email: fake.email,
|
|
342
|
+
balance: num(c.balance),
|
|
343
|
+
created_offset: offset(c.created),
|
|
344
|
+
...(keepMetadata && metadata ? { metadata: c.metadata } : {}),
|
|
345
|
+
};
|
|
346
|
+
});
|
|
347
|
+
const seedProducts = products.map((p) => {
|
|
348
|
+
const id = String(p.id);
|
|
349
|
+
verbatimId("prod", id);
|
|
350
|
+
dropOtherMetadata(p);
|
|
351
|
+
const original = str(p.name);
|
|
352
|
+
let name;
|
|
353
|
+
if (opts.keepNames && original) {
|
|
354
|
+
name = original;
|
|
355
|
+
sample("product_names", original);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
name = fakeProductName(id);
|
|
359
|
+
if (original)
|
|
360
|
+
manifest.scrub.replaced.product_names++;
|
|
361
|
+
}
|
|
362
|
+
return { id, name, active: p.active !== false };
|
|
363
|
+
});
|
|
364
|
+
const lineDefault = new Map();
|
|
365
|
+
const keptNickname = new Set();
|
|
366
|
+
const seedPrices = [...priceById.values()]
|
|
367
|
+
.filter((p) => productIds.has(String(p.product)))
|
|
368
|
+
.map((p) => {
|
|
369
|
+
const id = String(p.id);
|
|
370
|
+
verbatimId("price", id);
|
|
371
|
+
dropOtherMetadata(p);
|
|
372
|
+
const recurring = p.recurring;
|
|
373
|
+
const original = str(p.nickname);
|
|
374
|
+
let nickname;
|
|
375
|
+
if (original && opts.keepNames) {
|
|
376
|
+
nickname = original;
|
|
377
|
+
sample("price_nicknames", original);
|
|
378
|
+
keptNickname.add(id);
|
|
379
|
+
}
|
|
380
|
+
else if (original) {
|
|
381
|
+
nickname = fakePriceNickname(p);
|
|
382
|
+
manifest.scrub.replaced.price_nicknames++;
|
|
383
|
+
}
|
|
384
|
+
lineDefault.set(id, nickname ?? fakePriceNickname(p));
|
|
385
|
+
return {
|
|
386
|
+
id,
|
|
387
|
+
product: String(p.product),
|
|
388
|
+
unit_amount: num(p.unit_amount),
|
|
389
|
+
currency: String(p.currency ?? "usd"),
|
|
390
|
+
...(nickname ? { nickname } : {}),
|
|
391
|
+
active: p.active !== false,
|
|
392
|
+
recurring: recurring
|
|
393
|
+
? { interval: String(recurring.interval), interval_count: num(recurring.interval_count, 1) }
|
|
394
|
+
: null,
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
const seedPriceIds = new Set(seedPrices.map((p) => p.id));
|
|
398
|
+
let chargesAttached = 0;
|
|
399
|
+
const seedInvoices = [];
|
|
400
|
+
for (const inv of invoices) {
|
|
401
|
+
const invoiceId = String(inv.id);
|
|
402
|
+
const customerId = str(inv.customer);
|
|
403
|
+
const status = str(inv.status);
|
|
404
|
+
if (!customerId || !customerIds.has(customerId)) {
|
|
405
|
+
skipped.push(`invoice ${invoiceId}: customer unavailable`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (!status || !["draft", "open", "paid", "void", "uncollectible"].includes(status)) {
|
|
409
|
+
skipped.push(`invoice ${invoiceId}: unsupported status '${status}'`);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
let lines = inv.lines?.data ?? [];
|
|
413
|
+
if (inv.lines?.has_more) {
|
|
414
|
+
lines = await listAll(opts, `/v1/invoices/${invoiceId}/lines`, 1000);
|
|
415
|
+
}
|
|
416
|
+
if (lines.length === 0) {
|
|
417
|
+
skipped.push(`invoice ${invoiceId}: no line items`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
dropOtherMetadata(inv);
|
|
421
|
+
const seedLines = lines.map((line, lidx) => {
|
|
422
|
+
const priceId = str(line.price?.id);
|
|
423
|
+
const original = str(line.description);
|
|
424
|
+
let description;
|
|
425
|
+
if (opts.keepDescriptions) {
|
|
426
|
+
description = original ?? null;
|
|
427
|
+
if (original)
|
|
428
|
+
sample("line_descriptions", original);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
description = (priceId && lineDefault.get(priceId)) || `Line item ${lidx + 1}`;
|
|
432
|
+
if (priceId && keptNickname.has(priceId))
|
|
433
|
+
manifest.scrub.replaced.line_descriptions_from_kept_nickname++;
|
|
434
|
+
else if (original)
|
|
435
|
+
manifest.scrub.replaced.line_descriptions++;
|
|
436
|
+
}
|
|
437
|
+
return {
|
|
438
|
+
description,
|
|
439
|
+
amount: num(line.amount),
|
|
440
|
+
quantity: Math.max(1, num(line.quantity, 1)),
|
|
441
|
+
...(priceId && seedPriceIds.has(priceId) ? { price: priceId } : {}),
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
const invCharges = status === "paid"
|
|
445
|
+
? (chargesByInvoice.get(invoiceId) ?? [])
|
|
446
|
+
.sort((a, b) => num(a.created) - num(b.created))
|
|
447
|
+
.map((ch) => {
|
|
448
|
+
const id = remapId("ch", String(ch.id));
|
|
449
|
+
const refunded = num(ch.amount_refunded);
|
|
450
|
+
if (refunded > 0)
|
|
451
|
+
manifest.lossy.refunds_folded++;
|
|
452
|
+
if (ch.disputed === true)
|
|
453
|
+
manifest.lossy.disputed_charges.push(id);
|
|
454
|
+
return { id, amount: num(ch.amount), ...(refunded > 0 ? { amount_refunded: refunded } : {}) };
|
|
455
|
+
})
|
|
456
|
+
: [];
|
|
457
|
+
chargesAttached += invCharges.length;
|
|
458
|
+
seedInvoices.push({
|
|
459
|
+
id: remapId("in", invoiceId),
|
|
460
|
+
customer: remapId("cus", customerId),
|
|
461
|
+
currency: String(inv.currency ?? "usd"),
|
|
462
|
+
status,
|
|
463
|
+
created_offset: offset(inv.created),
|
|
464
|
+
lines: seedLines,
|
|
465
|
+
...(invCharges.length > 0 ? { charges: invCharges } : {}),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const seedSubscriptions = [];
|
|
469
|
+
for (const sub of subscriptions) {
|
|
470
|
+
const subId = String(sub.id);
|
|
471
|
+
const customerId = str(sub.customer);
|
|
472
|
+
const item = sub.items?.data?.[0];
|
|
473
|
+
const priceId = str(item?.price?.id);
|
|
474
|
+
if (!customerId || !customerIds.has(customerId) || !priceId || !seedPriceIds.has(priceId)) {
|
|
475
|
+
skipped.push(`subscription ${subId}: customer or price unavailable`);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (!priceById.get(priceId)?.recurring) {
|
|
479
|
+
skipped.push(`subscription ${subId}: price is not recurring`);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
dropOtherMetadata(sub);
|
|
483
|
+
const periodStart = num(sub.current_period_start, num(item?.current_period_start));
|
|
484
|
+
const periodEnd = num(sub.current_period_end, num(item?.current_period_end));
|
|
485
|
+
seedSubscriptions.push({
|
|
486
|
+
id: remapId("sub", subId),
|
|
487
|
+
customer: remapId("cus", customerId),
|
|
488
|
+
price: priceId,
|
|
489
|
+
quantity: Math.max(1, num(item?.quantity, 1)),
|
|
490
|
+
cancel_at_period_end: sub.cancel_at_period_end === true,
|
|
491
|
+
created_offset: offset(sub.created),
|
|
492
|
+
...(periodStart > 0 ? { current_period_start: periodStart } : {}),
|
|
493
|
+
...(periodEnd > periodStart && periodEnd > 0 ? { current_period_end: periodEnd } : {}),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
const seedDoc = {
|
|
497
|
+
name: opts.name,
|
|
498
|
+
version: 1,
|
|
499
|
+
epoch,
|
|
500
|
+
clock_mode: "manual",
|
|
501
|
+
data: {
|
|
502
|
+
customers: seedCustomers,
|
|
503
|
+
products: seedProducts,
|
|
504
|
+
prices: seedPrices,
|
|
505
|
+
invoices: seedInvoices,
|
|
506
|
+
subscriptions: seedSubscriptions,
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
if (manifest.lossy.refunds_folded > 0) {
|
|
510
|
+
manifest.lossy.notes.push(`${manifest.lossy.refunds_folded} charge(s) carry prior refunds as amount_refunded; the refund objects themselves are not seedable`);
|
|
511
|
+
}
|
|
512
|
+
if (manifest.lossy.disputed_charges.length > 0) {
|
|
513
|
+
manifest.lossy.notes.push(`${manifest.lossy.disputed_charges.length} disputed charge(s) import as ordinary charges: the twin does not model disputes`);
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
seedDoc,
|
|
517
|
+
manifest,
|
|
518
|
+
identities,
|
|
519
|
+
stats: {
|
|
520
|
+
customers: seedCustomers.length,
|
|
521
|
+
products: seedProducts.length,
|
|
522
|
+
prices: seedPrices.length,
|
|
523
|
+
invoices: seedInvoices.length,
|
|
524
|
+
charges_attached: chargesAttached,
|
|
525
|
+
subscriptions: seedSubscriptions.length,
|
|
526
|
+
skipped,
|
|
527
|
+
named,
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|