@pleaseai/emulate-autumn 0.1.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/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @pleaseai/emulate-autumn
2
+
3
+ [Autumn](https://useautumn.com) billing API emulator for local development and CI.
4
+
5
+ Implements the v1 RPC-style API autumn-js speaks (`/v1/<group>.<method>`): `customers.get_or_create`/`update`/`list`, `balances.track`/`check`, `plans.list`, `billing.attach`/`open_customer_portal`, plus a hosted checkout page that settles card-required trials and paid plans. Tested against the real `autumn-js` SDK.
6
+
7
+ Ported from the [UsefulSoftwareCo/emulate](https://github.com/UsefulSoftwareCo/emulate) fork.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @pleaseai/emulate-autumn
13
+ ```
14
+
15
+ Usually you do not install this directly — run it through the `@pleaseai/emulate` CLI.
16
+
17
+ ## Start
18
+
19
+ ```bash
20
+ npx @pleaseai/emulate --service autumn
21
+ ```
22
+
23
+ A single service starts on the base port (default `4000`); use `-p <port>` to change it.
24
+
25
+ ## Auth
26
+
27
+ Any `am_`-prefixed bearer token is accepted as the secret key:
28
+
29
+ ```bash
30
+ curl -X POST http://localhost:4000/v1/customers.get_or_create \
31
+ -H "Authorization: Bearer am_test_emulate" \
32
+ -H "Content-Type: application/json" \
33
+ -d '{"customer_id": "org_demo"}'
34
+ ```
35
+
36
+ Or point the SDK at the emulator:
37
+
38
+ ```ts
39
+ import { Autumn } from 'autumn-js'
40
+
41
+ const autumn = new Autumn({ secretKey: 'am_test_emulate', serverURL: 'http://localhost:4000' })
42
+ ```
43
+
44
+ ## Seed Config
45
+
46
+ ```yaml
47
+ autumn:
48
+ plans:
49
+ - id: free
50
+ name: Free
51
+ auto_enable: true
52
+ items:
53
+ - feature_id: executions
54
+ included: 100
55
+ - id: pro
56
+ name: Pro
57
+ price:
58
+ amount: 20
59
+ interval: month
60
+ items:
61
+ - feature_id: executions
62
+ included: 10000
63
+ customers:
64
+ - id: org_demo
65
+ subscriptions:
66
+ - plan_id: pro
67
+ status: active
68
+ ```
@@ -0,0 +1,117 @@
1
+ import { Entity, Collection, Store, ServicePlugin } from '@emulators/core';
2
+
3
+ interface AutumnSubscription {
4
+ /** Stable subscription id (e.g. `sub_emulate_1`). */
5
+ id?: string;
6
+ plan_id: string;
7
+ /** `active` | `trialing` | `scheduled` | `canceled`. */
8
+ status: string;
9
+ started_at?: number;
10
+ current_period_start?: number | null;
11
+ current_period_end?: number | null;
12
+ trial_ends_at?: number | null;
13
+ canceled_at?: number | null;
14
+ quantity?: number;
15
+ [key: string]: unknown;
16
+ }
17
+ interface AutumnCustomer extends Entity {
18
+ customer_id: string;
19
+ name: string | null;
20
+ email: string | null;
21
+ subscriptions: AutumnSubscription[];
22
+ /**
23
+ * Plan ids whose free trial this customer has already consumed. Once used,
24
+ * the plan's `trial_available` flips to false (Autumn offers a trial once).
25
+ */
26
+ trials_used?: string[];
27
+ }
28
+ interface AutumnTrackEvent extends Entity {
29
+ customer_id: string;
30
+ feature_id: string;
31
+ value: number;
32
+ }
33
+ interface AutumnPlanItem {
34
+ feature_id: string;
35
+ included?: number;
36
+ unlimited?: boolean;
37
+ price?: unknown;
38
+ }
39
+ interface AutumnPlan extends Entity {
40
+ plan_id: string;
41
+ name: string;
42
+ add_on: boolean;
43
+ auto_enable: boolean;
44
+ price: {
45
+ amount: number;
46
+ interval: string;
47
+ } | null;
48
+ free_trial: {
49
+ duration_length: number;
50
+ duration_type: string;
51
+ card_required: boolean;
52
+ } | null;
53
+ items: AutumnPlanItem[];
54
+ /** Rank used to classify an attach as upgrade vs downgrade (low to high). */
55
+ order: number;
56
+ }
57
+ /**
58
+ * A checkout session opened by `billing.attach` for a plan that needs payment
59
+ * (a price, or a card-required trial). Mirrors the real flow: the browser is
60
+ * redirected to a hosted checkout page; completing it sends the browser back
61
+ * to `success_url`, but the subscription only activates once the asynchronous
62
+ * Stripe webhook is processed, modelled here by `settle`.
63
+ */
64
+ interface AutumnCheckout extends Entity {
65
+ session_id: string;
66
+ customer_id: string;
67
+ plan_id: string;
68
+ success_url: string;
69
+ /**
70
+ * `pending` (checkout open) to `completed` (browser paid, webhook in flight)
71
+ * to `settled` (webhook processed, subscription active).
72
+ */
73
+ status: 'pending' | 'completed' | 'settled';
74
+ }
75
+
76
+ interface AutumnStore {
77
+ customers: Collection<AutumnCustomer>;
78
+ events: Collection<AutumnTrackEvent>;
79
+ plans: Collection<AutumnPlan>;
80
+ checkouts: Collection<AutumnCheckout>;
81
+ }
82
+ declare function getAutumnStore(store: Store): AutumnStore;
83
+
84
+ interface AutumnSeedPlan {
85
+ id: string;
86
+ name?: string;
87
+ add_on?: boolean;
88
+ auto_enable?: boolean;
89
+ price?: {
90
+ amount: number;
91
+ interval: string;
92
+ } | null;
93
+ free_trial?: {
94
+ duration_length: number;
95
+ duration_type: string;
96
+ card_required: boolean;
97
+ } | null;
98
+ items?: AutumnPlanItem[];
99
+ }
100
+ interface AutumnSeedConfig {
101
+ customers?: Array<{
102
+ id: string;
103
+ name?: string;
104
+ email?: string;
105
+ subscriptions?: AutumnSubscription[];
106
+ }>;
107
+ /**
108
+ * Plan catalog the emulator advertises via `plans.list` and attaches via
109
+ * `billing.attach`. In production these are synced from `autumn.config.ts`;
110
+ * the emulator has no such sync, so the application under test seeds them.
111
+ */
112
+ plans?: AutumnSeedPlan[];
113
+ }
114
+ declare function seedFromConfig(store: Store, _baseUrl: string, config: AutumnSeedConfig): void;
115
+ declare const autumnPlugin: ServicePlugin;
116
+
117
+ export { type AutumnCheckout, type AutumnCustomer, type AutumnPlan, type AutumnPlanItem, type AutumnSeedConfig, type AutumnSeedPlan, type AutumnStore, type AutumnSubscription, type AutumnTrackEvent, autumnPlugin, autumnPlugin as default, getAutumnStore, seedFromConfig };
package/dist/index.js ADDED
@@ -0,0 +1,650 @@
1
+ // src/serialize.ts
2
+ var DAY_MS = 864e5;
3
+ var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["active", "trialing"]);
4
+ function freeTrialMs(ft) {
5
+ const n = ft.duration_length;
6
+ switch (ft.duration_type) {
7
+ case "year":
8
+ return n * 365 * DAY_MS;
9
+ case "month":
10
+ return n * 30 * DAY_MS;
11
+ default:
12
+ return n * DAY_MS;
13
+ }
14
+ }
15
+ function ensureCustomer(as, id, data) {
16
+ const existing = as.customers.findOneBy("customer_id", id);
17
+ if (existing) {
18
+ return existing;
19
+ }
20
+ return as.customers.insert({
21
+ customer_id: id,
22
+ name: typeof data.name === "string" ? data.name : null,
23
+ email: typeof data.email === "string" ? data.email : null,
24
+ subscriptions: []
25
+ });
26
+ }
27
+ function activateSubscription(as, customer, plan, opts) {
28
+ const now = Date.now();
29
+ const trialing = opts.trial && plan.free_trial != null;
30
+ const periodEnd = trialing ? now + freeTrialMs(plan.free_trial) : now + 30 * DAY_MS;
31
+ const sub = {
32
+ id: `sub_emulate_${customer.id}_${plan.plan_id}`,
33
+ plan_id: plan.plan_id,
34
+ status: trialing ? "trialing" : "active",
35
+ started_at: now,
36
+ current_period_start: now,
37
+ current_period_end: periodEnd,
38
+ trial_ends_at: trialing ? periodEnd : null,
39
+ canceled_at: null,
40
+ quantity: 1
41
+ };
42
+ const trialsUsed = trialing ? Array.from(/* @__PURE__ */ new Set([...customer.trials_used ?? [], plan.plan_id])) : customer.trials_used ?? [];
43
+ as.customers.update(customer.id, { subscriptions: [sub], trials_used: trialsUsed });
44
+ }
45
+ function activeSubscription(customer) {
46
+ return (customer.subscriptions ?? []).find((s) => ACTIVE_STATUSES.has(s.status));
47
+ }
48
+ function usageFor(as, customerId, featureId) {
49
+ return as.events.all().filter((e) => e.customer_id === customerId && e.feature_id === featureId).reduce((sum, e) => sum + (e.value ?? 0), 0);
50
+ }
51
+ function serializeBalanceFeature(featureId) {
52
+ return {
53
+ id: featureId,
54
+ name: featureId,
55
+ type: "metered",
56
+ consumable: true,
57
+ event_names: [featureId],
58
+ archived: false
59
+ };
60
+ }
61
+ function serializeSubscription(sub) {
62
+ return {
63
+ id: sub.id ?? `sub_emulate_${sub.plan_id}`,
64
+ plan_id: sub.plan_id,
65
+ auto_enable: false,
66
+ add_on: false,
67
+ status: sub.status,
68
+ past_due: false,
69
+ canceled_at: sub.canceled_at ?? null,
70
+ expires_at: null,
71
+ trial_ends_at: sub.trial_ends_at ?? null,
72
+ started_at: sub.started_at ?? Date.now(),
73
+ current_period_start: sub.current_period_start ?? null,
74
+ current_period_end: sub.current_period_end ?? null,
75
+ quantity: sub.quantity ?? 1
76
+ };
77
+ }
78
+ function balancesFor(as, customer) {
79
+ const planId = activeSubscription(customer)?.plan_id ?? "free";
80
+ const plan = as.plans.findOneBy("plan_id", planId);
81
+ const balances = {};
82
+ for (const item of plan?.items ?? []) {
83
+ const unlimited = item.unlimited === true;
84
+ const granted = unlimited ? 0 : item.included ?? 0;
85
+ const usage = usageFor(as, customer.customer_id, item.feature_id);
86
+ balances[item.feature_id] = {
87
+ feature_id: item.feature_id,
88
+ feature: serializeBalanceFeature(item.feature_id),
89
+ granted,
90
+ remaining: unlimited ? 0 : Math.max(0, granted - usage),
91
+ usage,
92
+ unlimited,
93
+ overage_allowed: false,
94
+ max_purchase: null,
95
+ next_reset_at: null
96
+ };
97
+ }
98
+ return balances;
99
+ }
100
+ function balanceForFeature(as, customer, featureId) {
101
+ return balancesFor(as, customer)[featureId];
102
+ }
103
+ function serializeCustomer(as, customer) {
104
+ return {
105
+ id: customer.customer_id,
106
+ created_at: Date.parse(customer.created_at) || Date.now(),
107
+ name: customer.name,
108
+ email: customer.email,
109
+ fingerprint: null,
110
+ stripe_id: `cus_emulate_${customer.id}`,
111
+ env: "sandbox",
112
+ metadata: {},
113
+ send_email_receipts: true,
114
+ billing_controls: {},
115
+ subscriptions: (customer.subscriptions ?? []).map(serializeSubscription),
116
+ purchases: [],
117
+ balances: balancesFor(as, customer),
118
+ flags: {},
119
+ invoices: [],
120
+ products: [],
121
+ features: {}
122
+ };
123
+ }
124
+ function eligibilityFor(as, customer, plan) {
125
+ const subs = customer.subscriptions ?? [];
126
+ const subForPlan = subs.find((s) => s.plan_id === plan.plan_id && ACTIVE_STATUSES.has(s.status));
127
+ if (subForPlan) {
128
+ return {
129
+ status: "active",
130
+ canceling: subForPlan.canceled_at != null,
131
+ trialing: subForPlan.status === "trialing",
132
+ trial_available: false,
133
+ attach_action: "none"
134
+ };
135
+ }
136
+ const paidSub = subs.find((s) => ACTIVE_STATUSES.has(s.status) && s.plan_id !== "free");
137
+ if (plan.plan_id === "free") {
138
+ if (!paidSub) {
139
+ return { status: "active", canceling: false, trialing: false, attach_action: "none" };
140
+ }
141
+ return { canceling: false, trialing: false, trial_available: false, attach_action: "downgrade" };
142
+ }
143
+ const paidPlan = paidSub ? as.plans.findOneBy("plan_id", paidSub.plan_id) : void 0;
144
+ const trialUsed = (customer.trials_used ?? []).includes(plan.plan_id);
145
+ const attachAction = paidSub && paidPlan ? plan.order > paidPlan.order ? "upgrade" : "downgrade" : "upgrade";
146
+ return {
147
+ canceling: false,
148
+ trialing: false,
149
+ trial_available: plan.free_trial != null && !trialUsed,
150
+ attach_action: attachAction
151
+ };
152
+ }
153
+ function serializePlan(as, customer, plan) {
154
+ return {
155
+ id: plan.plan_id,
156
+ name: plan.name,
157
+ description: null,
158
+ group: null,
159
+ version: 1,
160
+ add_on: plan.add_on,
161
+ auto_enable: plan.auto_enable,
162
+ price: plan.price ? { amount: plan.price.amount, interval: plan.price.interval, interval_count: 1 } : null,
163
+ items: (plan.items ?? []).map((it) => ({
164
+ feature_id: it.feature_id,
165
+ included: it.included ?? 0,
166
+ unlimited: it.unlimited === true,
167
+ reset: null,
168
+ price: it.price ?? null
169
+ })),
170
+ free_trial: plan.free_trial ? {
171
+ duration_length: plan.free_trial.duration_length,
172
+ duration_type: plan.free_trial.duration_type,
173
+ card_required: plan.free_trial.card_required
174
+ } : void 0,
175
+ created_at: Date.parse(plan.created_at) || Date.now(),
176
+ env: "sandbox",
177
+ archived: false,
178
+ base_variant_id: null,
179
+ config: { ignore_past_due: false },
180
+ customer_eligibility: customer ? eligibilityFor(as, customer, plan) : void 0
181
+ };
182
+ }
183
+
184
+ // src/store.ts
185
+ function getAutumnStore(store) {
186
+ return {
187
+ customers: store.collection("autumn.customers", ["customer_id"]),
188
+ events: store.collection("autumn.events", ["customer_id", "feature_id"]),
189
+ plans: store.collection("autumn.plans", ["plan_id"]),
190
+ checkouts: store.collection("autumn.checkouts", ["session_id", "customer_id"])
191
+ };
192
+ }
193
+
194
+ // src/routes/api.ts
195
+ function autumnApiRoutes(ctx) {
196
+ const { app, store, baseUrl } = ctx;
197
+ const as = () => getAutumnStore(store);
198
+ app.post("/v1/customers.get_or_create", async (c) => {
199
+ const body = await c.req.json().catch(() => ({}));
200
+ const id = String(body.customer_id ?? body.customerId ?? body.id ?? "");
201
+ if (!id) {
202
+ return c.json({ message: "customer_id is required", code: "invalid_request" }, 400);
203
+ }
204
+ const data = body.customer_data ?? body;
205
+ const customer = ensureCustomer(as(), id, data);
206
+ return c.json(serializeCustomer(as(), customer));
207
+ });
208
+ app.post("/v1/customers.list", async (c) => {
209
+ const store2 = as();
210
+ const customers = store2.customers.all().map((customer) => serializeCustomer(store2, customer));
211
+ return c.json({ list: customers, total: customers.length, offset: 0, limit: 100 });
212
+ });
213
+ app.post("/v1/customers.update", async (c) => {
214
+ const body = await c.req.json().catch(() => ({}));
215
+ const id = String(body.customer_id ?? body.customerId ?? body.id ?? "");
216
+ const store2 = as();
217
+ const customer = store2.customers.findOneBy("customer_id", id);
218
+ if (!customer) {
219
+ return c.json({ message: "Customer not found", code: "not_found" }, 404);
220
+ }
221
+ const updated = store2.customers.update(customer.id, {
222
+ name: typeof body.name === "string" ? body.name : customer.name,
223
+ email: typeof body.email === "string" ? body.email : customer.email
224
+ });
225
+ return c.json(serializeCustomer(store2, updated));
226
+ });
227
+ app.post("/v1/balances.track", async (c) => {
228
+ const body = await c.req.json().catch(() => ({}));
229
+ const customerId = String(body.customer_id ?? body.customerId ?? "");
230
+ const featureId = String(body.feature_id ?? body.featureId ?? body.event_name ?? "");
231
+ if (!customerId || !featureId) {
232
+ return c.json({ message: "customer_id and feature_id are required", code: "invalid_request" }, 400);
233
+ }
234
+ const customer = ensureCustomer(as(), customerId, {});
235
+ const event = as().events.insert({
236
+ customer_id: customerId,
237
+ feature_id: featureId,
238
+ value: typeof body.value === "number" ? body.value : 1
239
+ });
240
+ return c.json({
241
+ id: `evt_emulate_${event.id}`,
242
+ code: "event_received",
243
+ customer_id: customerId,
244
+ feature_id: featureId,
245
+ value: event.value,
246
+ balance: balanceForFeature(as(), customer, featureId) ?? null
247
+ });
248
+ });
249
+ app.post("/v1/balances.check", async (c) => {
250
+ const body = await c.req.json().catch(() => ({}));
251
+ const customerId = String(body.customer_id ?? body.customerId ?? "");
252
+ const featureId = String(body.feature_id ?? body.featureId ?? "");
253
+ if (!customerId || !featureId) {
254
+ return c.json({ message: "customer_id and feature_id are required", code: "invalid_request" }, 400);
255
+ }
256
+ const requiredBalance = typeof body.required_balance === "number" ? body.required_balance : 1;
257
+ const entityId = typeof body.entity_id === "string" ? body.entity_id : null;
258
+ const store2 = as();
259
+ const customer = ensureCustomer(store2, customerId, body);
260
+ const balance = balanceForFeature(store2, customer, featureId);
261
+ const allowed = balance === void 0 || balance.unlimited || balance.overage_allowed || balance.remaining >= requiredBalance;
262
+ return c.json({
263
+ allowed,
264
+ customer_id: customerId,
265
+ entity_id: entityId,
266
+ required_balance: requiredBalance,
267
+ balance: balance ?? null,
268
+ flag: null
269
+ });
270
+ });
271
+ app.post("/v1/plans.list", async (c) => {
272
+ const body = await c.req.json().catch(() => ({}));
273
+ const customerId = String(body.customer_id ?? body.customerId ?? "");
274
+ const store2 = as();
275
+ const customer = customerId ? ensureCustomer(store2, customerId, body) : void 0;
276
+ const list = store2.plans.all().sort((a, b) => a.order - b.order).map((plan) => serializePlan(store2, customer, plan));
277
+ return c.json({ list });
278
+ });
279
+ app.post("/v1/billing.attach", async (c) => {
280
+ const body = await c.req.json().catch(() => ({}));
281
+ const customerId = String(body.customer_id ?? body.customerId ?? "");
282
+ const planId = String(body.plan_id ?? body.product_id ?? body.planId ?? body.productId ?? "");
283
+ const successUrl = String(body.success_url ?? body.successUrl ?? "");
284
+ if (!customerId || !planId) {
285
+ return c.json({ message: "customer_id and plan_id are required", code: "invalid_request" }, 400);
286
+ }
287
+ const store2 = as();
288
+ const customer = ensureCustomer(store2, customerId, body);
289
+ const plan = store2.plans.findOneBy("plan_id", planId);
290
+ const requiresPayment = plan ? plan.price != null || plan.free_trial?.card_required === true : true;
291
+ if (requiresPayment) {
292
+ const session = store2.checkouts.insert({
293
+ session_id: "",
294
+ customer_id: customerId,
295
+ plan_id: planId,
296
+ success_url: successUrl,
297
+ status: "pending"
298
+ });
299
+ const sessionId = `cs_emulate_${session.id}`;
300
+ store2.checkouts.update(session.id, { session_id: sessionId });
301
+ return c.json({
302
+ customer_id: customerId,
303
+ payment_url: `${baseUrl}/checkout/${sessionId}`,
304
+ invoice: null,
305
+ required_action: null
306
+ });
307
+ }
308
+ if (plan) {
309
+ activateSubscription(store2, customer, plan, { trial: plan.free_trial != null });
310
+ }
311
+ return c.json({ customer_id: customerId, payment_url: null, invoice: null, required_action: null });
312
+ });
313
+ app.post("/v1/billing.open_customer_portal", async (c) => {
314
+ const body = await c.req.json().catch(() => ({}));
315
+ const customerId = String(body.customer_id ?? body.customerId ?? "");
316
+ if (!customerId) {
317
+ return c.json({ message: "customer_id is required", code: "invalid_request" }, 400);
318
+ }
319
+ ensureCustomer(as(), customerId, body);
320
+ return c.json({ customer_id: customerId, url: `${baseUrl}/checkout/portal/${customerId}` });
321
+ });
322
+ app.post("/v1/features.list", async (c) => c.json({ list: [], total: 0, offset: 0, limit: 100 }));
323
+ app.post("/v1/events.list", async (c) => {
324
+ const events = as().events.all().map((event) => ({
325
+ id: `evt_emulate_${event.id}`,
326
+ customer_id: event.customer_id,
327
+ feature_id: event.feature_id,
328
+ value: event.value,
329
+ timestamp: Date.parse(event.created_at)
330
+ }));
331
+ return c.json({ list: events, total: events.length, offset: 0, limit: 100 });
332
+ });
333
+ }
334
+
335
+ // src/routes/checkout.ts
336
+ import { renderCardPage, renderCheckoutPage } from "@emulators/core";
337
+ var SERVICE_LABEL = "Autumn";
338
+ function settle(as, session) {
339
+ const customer = as.customers.findOneBy("customer_id", session.customer_id);
340
+ const plan = as.plans.findOneBy("plan_id", session.plan_id);
341
+ if (customer && plan) {
342
+ activateSubscription(as, customer, plan, { trial: plan.free_trial != null });
343
+ }
344
+ as.checkouts.update(session.id, { status: "settled" });
345
+ }
346
+ function checkoutRoutes(ctx) {
347
+ const { app, store } = ctx;
348
+ const as = () => getAutumnStore(store);
349
+ app.post("/checkout/settle", async (c) => {
350
+ const body = await c.req.json().catch(() => ({}));
351
+ const customerId = String(body.customer_id ?? body.customerId ?? "");
352
+ if (!customerId) {
353
+ return c.json({ message: "customer_id is required", code: "invalid_request" }, 400);
354
+ }
355
+ const store2 = as();
356
+ const sessions = store2.checkouts.findBy("customer_id", customerId).filter((s) => s.status === "completed");
357
+ for (const session of sessions) {
358
+ settle(store2, session);
359
+ }
360
+ return c.json({ settled: sessions.length });
361
+ });
362
+ app.get("/checkout/:sessionId", (c) => {
363
+ const store2 = as();
364
+ const session = store2.checkouts.findOneBy("session_id", c.req.param("sessionId"));
365
+ if (!session) {
366
+ return c.html(
367
+ renderCardPage(
368
+ "Checkout not found",
369
+ "This checkout session does not exist.",
370
+ '<p class="empty">The session id is invalid or has been removed.</p>',
371
+ SERVICE_LABEL
372
+ ),
373
+ 404
374
+ );
375
+ }
376
+ if (session.status === "settled") {
377
+ return c.html(
378
+ renderCardPage(
379
+ "Checkout complete",
380
+ "This subscription is already active.",
381
+ '<p class="empty check">Subscription active</p>',
382
+ SERVICE_LABEL
383
+ )
384
+ );
385
+ }
386
+ const plan = store2.plans.findOneBy("plan_id", session.plan_id);
387
+ const planName = plan?.name ?? session.plan_id;
388
+ const trialing = plan?.free_trial != null;
389
+ const dueCents = trialing ? 0 : Math.round((plan?.price?.amount ?? 0) * 100);
390
+ const lineItems = [
391
+ {
392
+ name: trialing ? `${planName} (free trial)` : planName,
393
+ quantity: 1,
394
+ unitPrice: dueCents,
395
+ totalPrice: dueCents,
396
+ currency: "usd"
397
+ }
398
+ ];
399
+ return c.html(
400
+ renderCheckoutPage(
401
+ {
402
+ merchantName: "Executor",
403
+ lineItems,
404
+ subtotal: dueCents,
405
+ total: dueCents,
406
+ currency: "usd",
407
+ sessionId: session.session_id,
408
+ cancelUrl: session.success_url || null
409
+ },
410
+ SERVICE_LABEL
411
+ )
412
+ );
413
+ });
414
+ app.post("/checkout/:sessionId/complete", async (c) => {
415
+ const store2 = as();
416
+ const session = store2.checkouts.findOneBy("session_id", c.req.param("sessionId"));
417
+ if (!session) {
418
+ return c.html(
419
+ renderCardPage("Checkout not found", "This checkout session does not exist.", "", SERVICE_LABEL),
420
+ 404
421
+ );
422
+ }
423
+ if (session.status === "pending") {
424
+ store2.checkouts.update(session.id, { status: "completed" });
425
+ }
426
+ return c.redirect(session.success_url || "/");
427
+ });
428
+ app.post("/checkout/:sessionId/settle", (c) => {
429
+ const store2 = as();
430
+ const session = store2.checkouts.findOneBy("session_id", c.req.param("sessionId"));
431
+ if (!session) {
432
+ return c.json({ message: "checkout not found", code: "not_found" }, 404);
433
+ }
434
+ settle(store2, session);
435
+ return c.json({ settled: 1 });
436
+ });
437
+ }
438
+
439
+ // src/routes/openapi.ts
440
+ function openapiRoutes({ app, baseUrl }) {
441
+ app.get("/openapi.json", (c) => c.json(buildSpec(baseUrl)));
442
+ }
443
+ function ok(description) {
444
+ return {
445
+ description,
446
+ content: { "application/json": { schema: { type: "object" } } }
447
+ };
448
+ }
449
+ function jsonBody(properties, required, description) {
450
+ return {
451
+ required: true,
452
+ description,
453
+ content: {
454
+ "application/json": {
455
+ schema: { type: "object", properties, required: [...required] }
456
+ }
457
+ }
458
+ };
459
+ }
460
+ function buildSpec(baseUrl) {
461
+ return {
462
+ openapi: "3.1.0",
463
+ info: {
464
+ title: "Autumn API (Emulated)",
465
+ version: "1.0.0",
466
+ description: "Emulated subset of the Autumn v1 API (RPC-style paths, all POST). Authenticate with a bearer secret key (mint one at POST /_emulate/credentials)."
467
+ },
468
+ servers: [{ url: baseUrl }],
469
+ components: {
470
+ securitySchemes: {
471
+ bearerAuth: {
472
+ type: "http",
473
+ scheme: "bearer",
474
+ description: "Autumn secret key, sent as `Authorization: Bearer am_sk_\u2026`."
475
+ }
476
+ }
477
+ },
478
+ security: [{ bearerAuth: [] }],
479
+ paths: {
480
+ "/v1/customers.get_or_create": {
481
+ post: {
482
+ operationId: "customers.get_or_create",
483
+ tags: ["customers"],
484
+ summary: "Get or create a customer",
485
+ requestBody: jsonBody(
486
+ {
487
+ customer_id: { type: "string" },
488
+ customer_data: {
489
+ type: "object",
490
+ properties: { name: { type: "string" }, email: { type: "string" } }
491
+ },
492
+ name: { type: "string" },
493
+ email: { type: "string" }
494
+ },
495
+ ["customer_id"],
496
+ "The customer to fetch or create."
497
+ ),
498
+ responses: { 200: ok("The customer."), 400: ok("Validation error.") }
499
+ }
500
+ },
501
+ "/v1/customers.list": {
502
+ post: {
503
+ operationId: "customers.list",
504
+ tags: ["customers"],
505
+ summary: "List customers",
506
+ responses: { 200: ok("Customer list.") }
507
+ }
508
+ },
509
+ "/v1/customers.update": {
510
+ post: {
511
+ operationId: "customers.update",
512
+ tags: ["customers"],
513
+ summary: "Update a customer",
514
+ requestBody: jsonBody(
515
+ {
516
+ customer_id: { type: "string" },
517
+ name: { type: "string" },
518
+ email: { type: "string" }
519
+ },
520
+ ["customer_id"],
521
+ "The customer fields to update."
522
+ ),
523
+ responses: { 200: ok("The updated customer."), 404: ok("Not found.") }
524
+ }
525
+ },
526
+ "/v1/balances.track": {
527
+ post: {
528
+ operationId: "balances.track",
529
+ tags: ["balances"],
530
+ summary: "Track a usage event",
531
+ requestBody: jsonBody(
532
+ {
533
+ customer_id: { type: "string" },
534
+ feature_id: { type: "string" },
535
+ event_name: { type: "string" },
536
+ value: { type: "number" }
537
+ },
538
+ ["customer_id", "feature_id"],
539
+ "The usage event to record (`event_name` is accepted as an alias for `feature_id`)."
540
+ ),
541
+ responses: { 200: ok("Event confirmation."), 400: ok("Validation error.") }
542
+ }
543
+ },
544
+ "/v1/balances.check": {
545
+ post: {
546
+ operationId: "balances.check",
547
+ tags: ["balances"],
548
+ summary: "Check feature access for a customer",
549
+ requestBody: jsonBody(
550
+ {
551
+ customer_id: { type: "string" },
552
+ feature_id: { type: "string" },
553
+ required_balance: { type: "number" }
554
+ },
555
+ ["customer_id", "feature_id"],
556
+ "The customer and feature to check. `required_balance` defaults to 1."
557
+ ),
558
+ responses: { 200: ok("Access decision with the feature balance."), 400: ok("Validation error.") }
559
+ }
560
+ },
561
+ "/v1/plans.list": {
562
+ post: {
563
+ operationId: "plans.list",
564
+ tags: ["plans"],
565
+ summary: "List plans",
566
+ responses: { 200: ok("Plan list.") }
567
+ }
568
+ },
569
+ "/v1/features.list": {
570
+ post: {
571
+ operationId: "features.list",
572
+ tags: ["features"],
573
+ summary: "List features",
574
+ responses: { 200: ok("Feature list.") }
575
+ }
576
+ },
577
+ "/v1/events.list": {
578
+ post: {
579
+ operationId: "events.list",
580
+ tags: ["events"],
581
+ summary: "List tracked usage events",
582
+ responses: { 200: ok("Event list.") }
583
+ }
584
+ }
585
+ }
586
+ };
587
+ }
588
+
589
+ // src/index.ts
590
+ function seedPlans(as, plans) {
591
+ plans.forEach((plan, index) => {
592
+ const fields = {
593
+ plan_id: plan.id,
594
+ name: plan.name ?? plan.id,
595
+ add_on: plan.add_on ?? false,
596
+ auto_enable: plan.auto_enable ?? false,
597
+ price: plan.price ?? null,
598
+ free_trial: plan.free_trial ?? null,
599
+ items: plan.items ?? [],
600
+ order: index
601
+ };
602
+ const existing = as.plans.findOneBy("plan_id", plan.id);
603
+ if (existing) {
604
+ as.plans.update(existing.id, fields);
605
+ } else {
606
+ as.plans.insert(fields);
607
+ }
608
+ });
609
+ }
610
+ function seedFromConfig(store, _baseUrl, config) {
611
+ const as = getAutumnStore(store);
612
+ if (config.plans) {
613
+ seedPlans(as, config.plans);
614
+ }
615
+ for (const customer of config.customers ?? []) {
616
+ const existing = as.customers.findOneBy("customer_id", customer.id);
617
+ if (existing) {
618
+ as.customers.update(existing.id, {
619
+ name: customer.name ?? existing.name,
620
+ email: customer.email ?? existing.email,
621
+ subscriptions: customer.subscriptions ?? existing.subscriptions
622
+ });
623
+ continue;
624
+ }
625
+ as.customers.insert({
626
+ customer_id: customer.id,
627
+ name: customer.name ?? null,
628
+ email: customer.email ?? null,
629
+ subscriptions: customer.subscriptions ?? []
630
+ });
631
+ }
632
+ }
633
+ var autumnPlugin = {
634
+ name: "autumn",
635
+ register(app, store, webhooks, baseUrl, tokenMap) {
636
+ const ctx = { app, store, webhooks, baseUrl, tokenMap };
637
+ autumnApiRoutes(ctx);
638
+ checkoutRoutes(ctx);
639
+ openapiRoutes(ctx);
640
+ },
641
+ seed(_store, _baseUrl) {
642
+ }
643
+ };
644
+ var index_default = autumnPlugin;
645
+ export {
646
+ autumnPlugin,
647
+ index_default as default,
648
+ getAutumnStore,
649
+ seedFromConfig
650
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@pleaseai/emulate-autumn",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/pleaseai/emulate.git",
9
+ "directory": "packages/autumn"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "bun": "./src/index.ts",
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsup --clean",
28
+ "dev": "tsup --watch",
29
+ "test": "bun test",
30
+ "clean": "rm -rf dist .turbo",
31
+ "type-check": "tsgo --noEmit"
32
+ },
33
+ "dependencies": {
34
+ "@emulators/core": "^0.6.0"
35
+ },
36
+ "devDependencies": {
37
+ "autumn-js": "1.2.28",
38
+ "tsup": "^8",
39
+ "typescript": "^6"
40
+ }
41
+ }