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.
Files changed (93) hide show
  1. package/LICENSE.txt +15 -0
  2. package/bin/worlds.js +53 -0
  3. package/dist/cli/argv.js +27 -0
  4. package/dist/cli/claims.js +250 -0
  5. package/dist/cli/commands.js +67 -0
  6. package/dist/cli/contract.js +142 -0
  7. package/dist/cli/demo.js +114 -0
  8. package/dist/cli/doctor.js +299 -0
  9. package/dist/cli/expect.js +197 -0
  10. package/dist/cli/home.js +18 -0
  11. package/dist/cli/import.js +530 -0
  12. package/dist/cli/index.js +1014 -0
  13. package/dist/cli/init.js +725 -0
  14. package/dist/cli/judge-command.js +184 -0
  15. package/dist/cli/judge.js +1495 -0
  16. package/dist/cli/load.js +8 -0
  17. package/dist/cli/packs.js +82 -0
  18. package/dist/cli/passk.js +1256 -0
  19. package/dist/cli/policy-starters.js +83 -0
  20. package/dist/cli/preload.js +45 -0
  21. package/dist/cli/report.js +579 -0
  22. package/dist/cli/rubric.js +727 -0
  23. package/dist/cli/run.js +266 -0
  24. package/dist/cli/scoreboard.js +432 -0
  25. package/dist/cli/selftest.js +157 -0
  26. package/dist/cli/stdio.js +25 -0
  27. package/dist/cli/tasks.js +1246 -0
  28. package/dist/cli/try.js +156 -0
  29. package/dist/cli/version.js +17 -0
  30. package/package.json +38 -0
  31. package/packs/cancellations.yaml +201 -0
  32. package/packs/credits.yaml +274 -0
  33. package/packs/invoices.yaml +206 -0
  34. package/packs/plan-changes.yaml +170 -0
  35. package/packs/refunds.yaml +437 -0
  36. package/preload/node-hooks.mjs +28 -0
  37. package/preload/node.mjs +19 -0
  38. package/preload/python/sitecustomize.py +88 -0
  39. package/preload/wrap.mjs +81 -0
  40. package/rubrics/acme-prod-library.json +1427 -0
  41. package/rubrics/acme-prod-shift.json +499 -0
  42. package/server/dist/admin/auth.js +145 -0
  43. package/server/dist/admin/routes.js +469 -0
  44. package/server/dist/admin/schemas.js +243 -0
  45. package/server/dist/app.js +112 -0
  46. package/server/dist/core/canonical.js +22 -0
  47. package/server/dist/core/db.js +294 -0
  48. package/server/dist/core/errors.js +136 -0
  49. package/server/dist/core/manager.js +337 -0
  50. package/server/dist/core/money.js +43 -0
  51. package/server/dist/core/nondeterministic.js +39 -0
  52. package/server/dist/core/paths.js +33 -0
  53. package/server/dist/core/prng.js +19 -0
  54. package/server/dist/core/protocol.js +1 -0
  55. package/server/dist/core/publicUrl.js +49 -0
  56. package/server/dist/core/seed.js +286 -0
  57. package/server/dist/core/seedExport.js +169 -0
  58. package/server/dist/core/seedLoader.js +156 -0
  59. package/server/dist/core/time.js +45 -0
  60. package/server/dist/core/world.js +361 -0
  61. package/server/dist/index.js +17 -0
  62. package/server/dist/main.js +42 -0
  63. package/server/dist/scenario/engine.js +172 -0
  64. package/server/dist/scenario/library.js +49 -0
  65. package/server/dist/twin/build.js +372 -0
  66. package/server/dist/twin/pagination.js +32 -0
  67. package/server/dist/twin/params.js +118 -0
  68. package/server/dist/twin/resources/charges.js +24 -0
  69. package/server/dist/twin/resources/customers.js +151 -0
  70. package/server/dist/twin/resources/events.js +26 -0
  71. package/server/dist/twin/resources/invoices.js +217 -0
  72. package/server/dist/twin/resources/payment_intents.js +146 -0
  73. package/server/dist/twin/resources/products.js +92 -0
  74. package/server/dist/twin/resources/refunds.js +103 -0
  75. package/server/dist/twin/resources/shared.js +113 -0
  76. package/server/dist/twin/resources/subscriptions.js +325 -0
  77. package/server/dist/twin/resources/tickets.js +128 -0
  78. package/server/dist/twin/router.js +279 -0
  79. package/server/dist/twin/unsupported.js +212 -0
  80. package/server/dist/webhooks/dispatcher.js +148 -0
  81. package/server/scenarios/auth-expiry.yaml +7 -0
  82. package/server/scenarios/intermittent-500s.yaml +9 -0
  83. package/server/scenarios/lock-timeouts.yaml +12 -0
  84. package/server/scenarios/rate-limit-storm.yaml +10 -0
  85. package/server/scenarios/slow-stripe.yaml +9 -0
  86. package/server/scenarios/stripe-storm-shift.yaml +15 -0
  87. package/server/scenarios/tickets-storm.yaml +10 -0
  88. package/server/scenarios/webhook-chaos.yaml +7 -0
  89. package/server/seeds/acme-prod-library.json +40470 -0
  90. package/server/seeds/acme-prod-shift.json +36544 -0
  91. package/server/seeds/acme-prod.json +35412 -0
  92. package/server/seeds/saas-billing-small.json +4792 -0
  93. package/server/seeds/saas-billing-smoke.json +160 -0
@@ -0,0 +1,113 @@
1
+ import { invalidRequest, resourceMissing, UNSUPPORTED_PARAMETER_CODE } from "../../core/errors.js";
2
+ import { chargeShape, invoicePrefixFor } from "../build.js";
3
+ export function surfaceOf(path) {
4
+ return path.startsWith("/api/v2") ? "zendesk" : "stripe";
5
+ }
6
+ export function getOr404(world, resource, id, label, param = "id") {
7
+ if (!id)
8
+ throw resourceMissing(String(id), label, param);
9
+ const stored = world.getObject(resource, id);
10
+ if (!stored)
11
+ throw resourceMissing(id, label, param);
12
+ return stored.data;
13
+ }
14
+ export function oneCurrencyPerBalance(detail, idempotencyKey, param) {
15
+ const stored = idempotencyKey
16
+ ? " The twin had to read the customer to know, so this outcome is stored under the request's idempotency key like any executed error — retry a corrected request under a new key."
17
+ : "";
18
+ return invalidRequest(`worlds models one currency per customer balance: ${detail}; nothing was changed.${stored} See docs/scope.md, "Semantics simplifications".`, { code: UNSUPPORTED_PARAMETER_CODE, param });
19
+ }
20
+ export function pendingCreditCurrenciesBeside(world, customerId, currency) {
21
+ const others = new Set();
22
+ for (const { data: item } of world.listObjects("invoiceitems")) {
23
+ if (item.customer !== customerId || item.invoice !== null || item.amount >= 0)
24
+ continue;
25
+ if (item.currency !== currency)
26
+ others.add(item.currency);
27
+ }
28
+ return [...others].sort();
29
+ }
30
+ export function refuseBesidePendingCredits(world, customerId, currency, consequence, idempotencyKey, param) {
31
+ const others = pendingCreditCurrenciesBeside(world, customerId, currency);
32
+ if (others.length === 0)
33
+ return;
34
+ throw oneCurrencyPerBalance(`${customerId} has pending credit lines in ${others.join(" and ")}, which the invoice collecting them may hand back as a credit in that currency, and ${consequence}. Collect those lines onto an invoice in their currency first (add a charge line if its total would otherwise be negative).`, idempotencyKey, param);
35
+ }
36
+ export function assignInvoiceNumber(world, invoice) {
37
+ const customer = invoice.customer;
38
+ const seq = world.incrCounter(`invnum:${customer}`);
39
+ invoice.number = `${invoicePrefixFor(customer)}-${String(seq).padStart(4, "0")}`;
40
+ }
41
+ export function finalizeInvoiceCore(world, invoice, opts = {}) {
42
+ const at = opts.at ?? world.clock;
43
+ invoice.status = "open";
44
+ assignInvoiceNumber(world, invoice);
45
+ invoice.status_transitions.finalized_at = at;
46
+ const stored = world.getObject("customers", invoice.customer);
47
+ if (stored) {
48
+ const customer = stored.data;
49
+ const total = invoice.total;
50
+ const currency = invoice.currency;
51
+ const sameCurrency = customer.currency === currency;
52
+ const balance = sameCurrency ? customer.balance : 0;
53
+ const applied = balance < 0 ? -Math.min(-balance, Math.max(total, 0)) : balance;
54
+ let due = total + applied;
55
+ let newBalance = balance - applied;
56
+ if (due < 0) {
57
+ if (customer.currency === null) {
58
+ refuseBesidePendingCredits(world, customer.id, currency, `this ${currency} invoice's negative total would pin its balance to ${currency}, beside which the twin could not bank that credit`, opts.idempotencyKey);
59
+ }
60
+ else if (!sameCurrency) {
61
+ throw oneCurrencyPerBalance(`${String(customer.id)} holds ${String(customer.currency)} and this ${currency} invoice's negative total would become a ${currency} credit`, opts.idempotencyKey);
62
+ }
63
+ newBalance += due;
64
+ due = 0;
65
+ }
66
+ if (applied !== 0 || due !== total) {
67
+ invoice.starting_balance = balance;
68
+ invoice.ending_balance = newBalance;
69
+ invoice.amount_due = due;
70
+ invoice.amount_remaining = due;
71
+ customer.balance = newBalance;
72
+ if (newBalance !== 0 && customer.currency === null)
73
+ customer.currency = currency;
74
+ world.updateObject("customers", customer.id, customer);
75
+ world.emitEvent("customer.updated", customer, { idempotencyKey: opts.idempotencyKey });
76
+ }
77
+ }
78
+ world.updateObject("invoices", invoice.id, invoice);
79
+ world.emitEvent("invoice.finalized", invoice, { idempotencyKey: opts.idempotencyKey });
80
+ if (invoice.amount_remaining === 0) {
81
+ invoice.status = "paid";
82
+ invoice.paid = true;
83
+ invoice.status_transitions.paid_at = at;
84
+ world.updateObject("invoices", invoice.id, invoice);
85
+ world.emitEvent("invoice.paid", invoice, { idempotencyKey: opts.idempotencyKey });
86
+ }
87
+ }
88
+ export function settleInvoice(world, invoice, opts = {}) {
89
+ const amount = invoice.amount_remaining;
90
+ const charge = chargeShape({
91
+ id: world.nextId("ch"),
92
+ created: opts.chargeCreated ?? world.clock,
93
+ amount,
94
+ currency: invoice.currency,
95
+ customer: invoice.customer,
96
+ invoice: invoice.id,
97
+ status: "succeeded",
98
+ description: `Payment for invoice ${invoice.number ?? invoice.id}`,
99
+ });
100
+ world.insertObject("charges", charge);
101
+ invoice.status = "paid";
102
+ invoice.paid = true;
103
+ invoice.attempted = true;
104
+ invoice.attempt_count = invoice.attempt_count + 1;
105
+ invoice.amount_paid = invoice.amount_paid + amount;
106
+ invoice.amount_remaining = 0;
107
+ invoice.charge = charge.id;
108
+ invoice.status_transitions.paid_at = opts.chargeCreated ?? world.clock;
109
+ world.updateObject("invoices", invoice.id, invoice);
110
+ world.emitEvent("charge.succeeded", charge, { idempotencyKey: opts.idempotencyKey });
111
+ world.emitEvent("invoice.paid", invoice, { idempotencyKey: opts.idempotencyKey });
112
+ return { charge };
113
+ }
@@ -0,0 +1,325 @@
1
+ import { invalidRequest, validationError } from "../../core/errors.js";
2
+ import { addInterval } from "../../core/time.js";
3
+ import { invoiceItemShape, invoiceShape, lineItemShape, subscriptionShape } from "../build.js";
4
+ import { paginate } from "../pagination.js";
5
+ import { getBool, getMetadata, getString, requireString } from "../params.js";
6
+ import { finalizeInvoiceCore, getOr404, oneCurrencyPerBalance, refuseBesidePendingCredits, settleInvoice, } from "./shared.js";
7
+ function billingAnchorDay(sub) {
8
+ const anchor = typeof sub.billing_cycle_anchor === "number" ? sub.billing_cycle_anchor : sub.current_period_start;
9
+ return typeof anchor === "number" ? new Date(anchor * 1000).getUTCDate() : undefined;
10
+ }
11
+ function priceInterval(price) {
12
+ const recurring = price.recurring;
13
+ if (!recurring)
14
+ throw invalidRequest(`The price specified is set to \`type=one_time\` but this field only accepts prices with \`type=recurring\`.`, { param: "items[0][price]" });
15
+ return { interval: recurring.interval, count: recurring.interval_count };
16
+ }
17
+ function billPeriod(world, sub, opts) {
18
+ const item = sub.items.data[0];
19
+ const price = item.price;
20
+ const quantity = item.quantity;
21
+ const amount = price.unit_amount * quantity;
22
+ const product = world.getObject("products", price.product)?.data;
23
+ const line = lineItemShape({
24
+ id: world.nextId("il"),
25
+ amount,
26
+ currency: sub.currency,
27
+ description: `1 × ${product?.name ?? "subscription"} (at ${formatUnit(price)})`,
28
+ period: { start: sub.current_period_start, end: sub.current_period_end },
29
+ price,
30
+ quantity,
31
+ subscription: sub.id,
32
+ type: "subscription",
33
+ });
34
+ const pendingItems = world
35
+ .listObjects("invoiceitems")
36
+ .map((o) => o.data)
37
+ .filter((i) => i.customer === sub.customer && i.invoice === null && i.currency === sub.currency)
38
+ .reverse();
39
+ const invoiceId = world.nextId("in");
40
+ const sweptLines = pendingItems.map((item) => lineItemShape({
41
+ id: world.nextId("il"),
42
+ amount: item.amount,
43
+ currency: item.currency,
44
+ description: item.description,
45
+ invoice_item: item.id,
46
+ period: item.period,
47
+ price: item.price ?? null,
48
+ quantity: item.quantity,
49
+ type: "invoiceitem",
50
+ }));
51
+ const invoice = invoiceShape({
52
+ id: invoiceId,
53
+ created: opts.at,
54
+ customer: sub.customer,
55
+ currency: sub.currency,
56
+ lines: [...sweptLines, line],
57
+ subscription: sub.id,
58
+ billing_reason: opts.reason,
59
+ period_start: sub.current_period_start,
60
+ period_end: sub.current_period_end,
61
+ });
62
+ world.insertObject("invoices", invoice);
63
+ for (const item of pendingItems) {
64
+ item.invoice = invoiceId;
65
+ world.updateObject("invoiceitems", item.id, item);
66
+ }
67
+ world.emitEvent("invoice.created", invoice, { idempotencyKey: opts.idempotencyKey });
68
+ finalizeInvoiceCore(world, invoice, { idempotencyKey: opts.idempotencyKey, at: opts.at });
69
+ if (invoice.amount_remaining > 0) {
70
+ settleInvoice(world, invoice, { idempotencyKey: opts.idempotencyKey, chargeCreated: opts.at });
71
+ }
72
+ sub.latest_invoice = invoice.id;
73
+ return invoice;
74
+ }
75
+ function formatUnit(price) {
76
+ const cents = price.unit_amount;
77
+ return `${(cents / 100).toFixed(2)} ${price.currency.toUpperCase()}`;
78
+ }
79
+ function create(ctx) {
80
+ const { world, params } = ctx;
81
+ const customerId = requireString(params, "customer");
82
+ const items = params.items;
83
+ if (!items || !Array.isArray(items) || items.length === 0 || !items[0]?.price) {
84
+ throw validationError("Missing required param: items[0][price].", {
85
+ code: "parameter_missing",
86
+ param: "items[0][price]",
87
+ });
88
+ }
89
+ if (items.length > 1) {
90
+ throw validationError("The twin supports exactly one item per subscription.", { param: "items" });
91
+ }
92
+ const quantity = items[0].quantity !== undefined ? Number(items[0].quantity) : 1;
93
+ if (!Number.isInteger(quantity) || quantity < 1)
94
+ throw validationError(`Invalid quantity`, { param: "items[0][quantity]" });
95
+ const cancelAtPeriodEnd = getBool(params, "cancel_at_period_end") ?? false;
96
+ const metadata = getMetadata(params) ?? {};
97
+ getOr404(world, "customers", customerId, "customer", "customer");
98
+ const price = getOr404(world, "prices", String(items[0].price), "price", "items[0][price]");
99
+ const { interval, count } = priceInterval(price);
100
+ const now = world.clock;
101
+ const sub = subscriptionShape({
102
+ id: world.nextId("sub"),
103
+ created: now,
104
+ customer: customerId,
105
+ currency: price.currency,
106
+ itemId: world.nextId("si"),
107
+ price,
108
+ quantity,
109
+ current_period_start: now,
110
+ current_period_end: addInterval(now, interval, count),
111
+ cancel_at_period_end: cancelAtPeriodEnd,
112
+ metadata,
113
+ });
114
+ world.insertObject("subscriptions", sub);
115
+ world.emitEvent("customer.subscription.created", sub, { idempotencyKey: ctx.idempotencyKey });
116
+ billPeriod(world, sub, { reason: "subscription_create", at: now, idempotencyKey: ctx.idempotencyKey });
117
+ world.updateObject("subscriptions", sub.id, sub);
118
+ return { body: sub };
119
+ }
120
+ function retrieve(ctx) {
121
+ return { body: getOr404(ctx.world, "subscriptions", ctx.pathParams.id, "subscription") };
122
+ }
123
+ function parsePlanChange(params) {
124
+ const prorationBehavior = getString(params, "proration_behavior") ?? "create_prorations";
125
+ if (!["create_prorations", "none"].includes(prorationBehavior)) {
126
+ throw validationError(`Invalid proration_behavior: ${prorationBehavior}`, { param: "proration_behavior" });
127
+ }
128
+ const items = params.items;
129
+ if (!items || !Array.isArray(items) || items.length === 0)
130
+ return undefined;
131
+ if (items.length > 1)
132
+ throw validationError("The twin supports exactly one item per subscription.", { param: "items" });
133
+ const requested = items[0];
134
+ const quantity = requested.quantity !== undefined ? Number(requested.quantity) : undefined;
135
+ if (quantity !== undefined && (!Number.isInteger(quantity) || quantity < 1))
136
+ throw validationError(`Invalid quantity`, { param: "items[0][quantity]" });
137
+ return {
138
+ itemId: requested.id,
139
+ price: requested.price !== undefined ? String(requested.price) : undefined,
140
+ quantity,
141
+ prorationBehavior,
142
+ };
143
+ }
144
+ function applyPlanChange(ctx, sub, change) {
145
+ const { world } = ctx;
146
+ const item = sub.items.data[0];
147
+ if (change.itemId !== undefined && change.itemId !== item.id) {
148
+ throw invalidRequest(`No such subscription item: '${change.itemId}'`, {
149
+ code: "resource_missing",
150
+ status: 404,
151
+ param: "items[0][id]",
152
+ });
153
+ }
154
+ if (change.price === undefined && change.quantity === undefined)
155
+ return;
156
+ const oldPrice = item.price;
157
+ const oldQuantity = item.quantity;
158
+ const newPrice = change.price !== undefined ? getOr404(world, "prices", change.price, "price", "items[0][price]") : oldPrice;
159
+ priceInterval(newPrice);
160
+ if (newPrice.currency !== sub.currency) {
161
+ throw invalidRequest(`Cannot change a subscription from ${String(sub.currency).toUpperCase()} to ${String(newPrice.currency).toUpperCase()}. Currency changes require a new subscription.`, { param: "items[0][price]" });
162
+ }
163
+ const newQuantity = change.quantity ?? oldQuantity;
164
+ if (newPrice.id === oldPrice.id && newQuantity === oldQuantity)
165
+ return;
166
+ if (change.prorationBehavior === "create_prorations") {
167
+ const periodStart = sub.current_period_start;
168
+ const periodEnd = sub.current_period_end;
169
+ const fraction = Math.max(0, Math.min(1, (periodEnd - world.clock) / (periodEnd - periodStart)));
170
+ const oldUnused = Math.round(oldPrice.unit_amount * oldQuantity * fraction);
171
+ const newRemaining = Math.round(newPrice.unit_amount * newQuantity * fraction);
172
+ if (oldUnused !== 0 || newRemaining !== 0) {
173
+ const customer = world.getObject("customers", sub.customer)?.data;
174
+ const wayOut = "pass proration_behavior=none to change the plan without prorations";
175
+ if (customer && customer.currency === null) {
176
+ refuseBesidePendingCredits(world, customer.id, sub.currency, `the prorations of this ${String(sub.currency)} subscription would be credit lines in a second currency, which the twin could not bank beside the first — ${wayOut}`, ctx.idempotencyKey, "proration_behavior");
177
+ }
178
+ else if (customer && customer.currency !== sub.currency) {
179
+ throw oneCurrencyPerBalance(`${String(customer.id)} holds ${String(customer.currency)} and the prorations of this ${String(sub.currency)} subscription would land on its next invoice, whose negative total the twin could not bank as a ${String(sub.currency)} credit beside that balance — ${wayOut}`, ctx.idempotencyKey, "proration_behavior");
180
+ }
181
+ }
182
+ const period = { start: world.clock, end: periodEnd };
183
+ const bookProration = (amount, description, price, quantity) => {
184
+ const proration = invoiceItemShape({
185
+ id: world.nextId("ii"),
186
+ created: world.clock,
187
+ customer: sub.customer,
188
+ amount,
189
+ currency: sub.currency,
190
+ description,
191
+ quantity,
192
+ price,
193
+ metadata: {},
194
+ });
195
+ proration.proration = true;
196
+ proration.period = period;
197
+ world.insertObject("invoiceitems", proration);
198
+ world.emitEvent("invoiceitem.created", proration, { idempotencyKey: ctx.idempotencyKey });
199
+ };
200
+ if (oldUnused !== 0)
201
+ bookProration(-oldUnused, `Unused time on ${oldPrice.nickname ?? oldPrice.id}`, oldPrice, oldQuantity);
202
+ if (newRemaining !== 0)
203
+ bookProration(newRemaining, `Remaining time on ${newPrice.nickname ?? newPrice.id}`, newPrice, newQuantity);
204
+ }
205
+ item.price = newPrice;
206
+ item.quantity = newQuantity;
207
+ }
208
+ function update(ctx) {
209
+ const { world, params, pathParams } = ctx;
210
+ const change = parsePlanChange(params);
211
+ const cancelAtPeriodEnd = getBool(params, "cancel_at_period_end");
212
+ const metadata = getMetadata(params);
213
+ const sub = getOr404(world, "subscriptions", pathParams.id, "subscription");
214
+ if (sub.status === "canceled") {
215
+ throw invalidRequest(`You cannot update a canceled subscription.`);
216
+ }
217
+ if (change !== undefined)
218
+ applyPlanChange(ctx, sub, change);
219
+ if (cancelAtPeriodEnd !== undefined) {
220
+ sub.cancel_at_period_end = cancelAtPeriodEnd;
221
+ sub.cancel_at = cancelAtPeriodEnd ? sub.current_period_end : null;
222
+ sub.cancellation_details.reason = cancelAtPeriodEnd ? "cancellation_requested" : null;
223
+ }
224
+ if (metadata !== undefined)
225
+ sub.metadata = { ...sub.metadata, ...metadata };
226
+ world.updateObject("subscriptions", sub.id, sub);
227
+ world.emitEvent("customer.subscription.updated", sub, { idempotencyKey: ctx.idempotencyKey });
228
+ return { body: sub };
229
+ }
230
+ function cancelNow(ctx) {
231
+ const { world, pathParams } = ctx;
232
+ const sub = getOr404(world, "subscriptions", pathParams.id, "subscription");
233
+ if (sub.status === "canceled") {
234
+ throw invalidRequest(`No such subscription: '${pathParams.id}'`, {
235
+ code: "resource_missing",
236
+ status: 404,
237
+ param: "id",
238
+ });
239
+ }
240
+ sub.status = "canceled";
241
+ sub.canceled_at = world.clock;
242
+ sub.ended_at = world.clock;
243
+ sub.cancel_at_period_end = false;
244
+ sub.cancellation_details.reason = "cancellation_requested";
245
+ world.updateObject("subscriptions", sub.id, sub);
246
+ world.emitEvent("customer.subscription.deleted", sub, { idempotencyKey: ctx.idempotencyKey });
247
+ return { body: sub };
248
+ }
249
+ function list(ctx) {
250
+ const { world, params } = ctx;
251
+ let items = world.listObjects("subscriptions").map((o) => o.data);
252
+ const customer = getString(params, "customer");
253
+ if (customer !== undefined)
254
+ items = items.filter((s) => s.customer === customer);
255
+ const status = getString(params, "status");
256
+ if (status !== undefined && status !== "all")
257
+ items = items.filter((s) => s.status === status);
258
+ else if (status === undefined)
259
+ items = items.filter((s) => s.status !== "canceled");
260
+ const priceId = getString(params, "price");
261
+ if (priceId !== undefined) {
262
+ items = items.filter((s) => s.items.data[0].price.id === priceId);
263
+ }
264
+ return { body: paginate(items, params, "/v1/subscriptions", "subscription") };
265
+ }
266
+ export const MAX_RENEWALS_PER_ADVANCE = 1000;
267
+ export class RenewalFailed extends Error {
268
+ subscription;
269
+ boundary;
270
+ reason;
271
+ constructor(subscription, boundary, cause) {
272
+ const reason = cause instanceof Error ? cause.message : String(cause);
273
+ super(`worlds could not renew subscription ${subscription} at its period boundary ${boundary}: ${reason} The clock movement was rolled back — the clock did not move and nothing was written — and every movement across this boundary fails the same way until the world is reset or, with the clock in manual mode, a line in the invoice's currency brings its total to zero or above or the subscription is set to cancel_at_period_end.`);
274
+ this.name = "RenewalFailed";
275
+ this.subscription = subscription;
276
+ this.boundary = boundary;
277
+ this.reason = reason;
278
+ }
279
+ }
280
+ export function processRenewals(world) {
281
+ let billed = 0;
282
+ let pending = 0;
283
+ const subs = world.listObjects("subscriptions").reverse();
284
+ for (const stored of subs) {
285
+ const sub = stored.data;
286
+ if (sub.status !== "active")
287
+ continue;
288
+ let renewed = 0;
289
+ while (sub.current_period_end <= world.clock && renewed < MAX_RENEWALS_PER_ADVANCE) {
290
+ const boundary = sub.current_period_end;
291
+ if (sub.cancel_at_period_end) {
292
+ sub.status = "canceled";
293
+ sub.canceled_at = boundary;
294
+ sub.ended_at = boundary;
295
+ world.updateObject("subscriptions", sub.id, sub);
296
+ world.emitEvent("customer.subscription.deleted", sub);
297
+ break;
298
+ }
299
+ const item = sub.items.data[0];
300
+ const { interval, count } = priceInterval(item.price);
301
+ sub.current_period_start = boundary;
302
+ sub.current_period_end = addInterval(boundary, interval, count, billingAnchorDay(sub));
303
+ try {
304
+ billPeriod(world, sub, { reason: "subscription_cycle", at: boundary });
305
+ }
306
+ catch (err) {
307
+ throw new RenewalFailed(sub.id, boundary, err);
308
+ }
309
+ world.updateObject("subscriptions", sub.id, sub);
310
+ world.emitEvent("customer.subscription.updated", sub);
311
+ billed++;
312
+ renewed++;
313
+ }
314
+ if (sub.status === "active" && sub.current_period_end <= world.clock)
315
+ pending++;
316
+ }
317
+ return { billed, pending };
318
+ }
319
+ export const subscriptionRoutes = [
320
+ { method: "POST", pattern: "/v1/subscriptions", handler: create },
321
+ { method: "GET", pattern: "/v1/subscriptions", handler: list },
322
+ { method: "GET", pattern: "/v1/subscriptions/:id", handler: retrieve },
323
+ { method: "POST", pattern: "/v1/subscriptions/:id", handler: update },
324
+ { method: "DELETE", pattern: "/v1/subscriptions/:id", handler: cancelNow },
325
+ ];
@@ -0,0 +1,128 @@
1
+ import { ZendeskError } from "../../core/errors.js";
2
+ import { isoFromClock, ticketShape } from "../build.js";
3
+ const STATUSES = ["new", "open", "pending", "hold", "solved", "closed"];
4
+ const PRIORITIES = ["low", "normal", "high", "urgent"];
5
+ function ticketId(raw) {
6
+ return raw.endsWith(".json") ? raw.slice(0, -5) : raw;
7
+ }
8
+ function notFound() {
9
+ return new ZendeskError(404, "RecordNotFound", "Not found");
10
+ }
11
+ function invalid(description, details) {
12
+ return new ZendeskError(422, "RecordInvalid", description, details);
13
+ }
14
+ function commentPublic(v) {
15
+ if (v === undefined || v === true || v === "true")
16
+ return true;
17
+ if (v === false || v === "false")
18
+ return false;
19
+ throw invalid(`Comment public: ${JSON.stringify(v)} is not a boolean`);
20
+ }
21
+ function wireTicket(data) {
22
+ const { comments: _comments, created: _created, ...rest } = data;
23
+ return rest;
24
+ }
25
+ function getTicket(ctx, rawId) {
26
+ const stored = ctx.world.getObject("tickets", ticketId(rawId));
27
+ if (!stored)
28
+ throw notFound();
29
+ return stored.data;
30
+ }
31
+ function create(ctx) {
32
+ const { world, params } = ctx;
33
+ const t = (params.ticket ?? {});
34
+ const subject = typeof t.subject === "string" ? t.subject : undefined;
35
+ const comment = (t.comment ?? {});
36
+ const body = typeof comment.body === "string" ? comment.body : typeof t.description === "string" ? t.description : undefined;
37
+ if (!subject || !body) {
38
+ throw invalid("Record validation errors", { base: [{ description: "Subject and comment body are required" }] });
39
+ }
40
+ const requester = (t.requester ?? {});
41
+ const priority = typeof t.priority === "string" ? t.priority : "normal";
42
+ if (!PRIORITIES.includes(priority)) {
43
+ throw invalid(`Priority: ${priority} is not a valid priority`);
44
+ }
45
+ const id = String(world.incrCounter("ticket_id"));
46
+ const ticket = ticketShape({
47
+ id,
48
+ created: world.clock,
49
+ subject,
50
+ description: body,
51
+ priority: priority,
52
+ status: "new",
53
+ requester: {
54
+ name: typeof requester.name === "string" ? requester.name : "Anonymous",
55
+ email: typeof requester.email === "string" ? requester.email : "anonymous@example.com",
56
+ },
57
+ tags: Array.isArray(t.tags) ? t.tags : [],
58
+ });
59
+ world.insertObject("tickets", ticket);
60
+ world.emitEvent("ticket.created", ticket, { idempotencyKey: ctx.idempotencyKey });
61
+ return { status: 201, body: { ticket: wireTicket(ticket) } };
62
+ }
63
+ function retrieve(ctx) {
64
+ return { body: { ticket: wireTicket(getTicket(ctx, ctx.pathParams.id)) } };
65
+ }
66
+ function list(ctx) {
67
+ const { world, params } = ctx;
68
+ let tickets = world
69
+ .listObjects("tickets")
70
+ .map((o) => o.data)
71
+ .reverse();
72
+ const status = typeof params.status === "string" ? params.status : undefined;
73
+ if (status !== undefined) {
74
+ if (!STATUSES.includes(status))
75
+ throw invalid(`Status: ${status} is not a valid status`);
76
+ tickets = tickets.filter((t) => t.status === status);
77
+ }
78
+ return { body: { tickets: tickets.map(wireTicket), count: tickets.length, next_page: null, previous_page: null } };
79
+ }
80
+ function update(ctx) {
81
+ const { world, params } = ctx;
82
+ const ticket = getTicket(ctx, ctx.pathParams.id);
83
+ if (ticket.status === "closed") {
84
+ throw invalid("Status: closed prevents ticket update");
85
+ }
86
+ const t = (params.ticket ?? {});
87
+ const status = typeof t.status === "string" ? t.status : undefined;
88
+ if (status !== undefined) {
89
+ if (!STATUSES.includes(status))
90
+ throw invalid(`Status: ${status} is not a valid status`);
91
+ ticket.status = status;
92
+ }
93
+ const priority = typeof t.priority === "string" ? t.priority : undefined;
94
+ if (priority !== undefined) {
95
+ if (!PRIORITIES.includes(priority))
96
+ throw invalid(`Priority: ${priority} is not a valid priority`);
97
+ ticket.priority = priority;
98
+ }
99
+ if (Array.isArray(t.tags))
100
+ ticket.tags = t.tags;
101
+ const comment = t.comment;
102
+ if (comment && typeof comment.body === "string") {
103
+ const comments = ticket.comments;
104
+ comments.push({
105
+ id: comments.length + 1,
106
+ author: "agent",
107
+ body: comment.body,
108
+ public: commentPublic(comment.public),
109
+ created_at: isoFromClock(world.clock),
110
+ });
111
+ }
112
+ ticket.updated_at = isoFromClock(world.clock);
113
+ world.updateObject("tickets", ticket.id, ticket);
114
+ world.emitEvent("ticket.updated", ticket, { idempotencyKey: ctx.idempotencyKey });
115
+ return { body: { ticket: wireTicket(ticket) } };
116
+ }
117
+ function comments(ctx) {
118
+ const ticket = getTicket(ctx, ctx.pathParams.id);
119
+ const list = ticket.comments;
120
+ return { body: { comments: list, count: list.length, next_page: null, previous_page: null } };
121
+ }
122
+ export const ticketRoutes = [
123
+ { method: "POST", pattern: "/api/v2/tickets.json", handler: create },
124
+ { method: "GET", pattern: "/api/v2/tickets.json", handler: list },
125
+ { method: "GET", pattern: "/api/v2/tickets/:id", handler: retrieve },
126
+ { method: "PUT", pattern: "/api/v2/tickets/:id", handler: update },
127
+ { method: "GET", pattern: "/api/v2/tickets/:id/comments.json", handler: comments },
128
+ ];