@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 +68 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +650 -0
- package/package.json +41 -0
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
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|