@mesub/node 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +840 -2
- package/dist/chunk-43Q4PNF7.cjs +480 -0
- package/dist/chunk-43Q4PNF7.cjs.map +1 -0
- package/dist/chunk-4AAPR37Y.js +120 -0
- package/dist/chunk-4AAPR37Y.js.map +1 -0
- package/dist/chunk-4JSJOX35.cjs +2040 -0
- package/dist/chunk-4JSJOX35.cjs.map +1 -0
- package/dist/chunk-66VVEXMU.cjs +340 -0
- package/dist/chunk-66VVEXMU.cjs.map +1 -0
- package/dist/chunk-J2CE4QWV.js +340 -0
- package/dist/chunk-J2CE4QWV.js.map +1 -0
- package/dist/chunk-JJWHMB2X.js +480 -0
- package/dist/chunk-JJWHMB2X.js.map +1 -0
- package/dist/chunk-NOS4FWWQ.js +2040 -0
- package/dist/chunk-NOS4FWWQ.js.map +1 -0
- package/dist/chunk-SJRQDWTI.cjs +120 -0
- package/dist/chunk-SJRQDWTI.cjs.map +1 -0
- package/dist/client-BEuDegEY.d.cts +765 -0
- package/dist/client-CXQLkSAJ.d.ts +765 -0
- package/dist/errors-BN4ZWUfM.d.ts +112 -0
- package/dist/errors-DcLfunS-.d.cts +112 -0
- package/dist/express.cjs +129 -0
- package/dist/express.cjs.map +1 -0
- package/dist/express.d.cts +57 -0
- package/dist/express.d.ts +57 -0
- package/dist/express.js +129 -0
- package/dist/express.js.map +1 -0
- package/dist/guard-CaXiHMVF.d.ts +51 -0
- package/dist/guard-D4AB5HtN.d.cts +51 -0
- package/dist/index.cjs +39 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +44 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/nest.cjs +62 -0
- package/dist/nest.cjs.map +1 -0
- package/dist/nest.d.cts +57 -0
- package/dist/nest.d.ts +57 -0
- package/dist/nest.js +62 -0
- package/dist/nest.js.map +1 -0
- package/dist/next.cjs +113 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +63 -0
- package/dist/next.d.ts +63 -0
- package/dist/next.js +113 -0
- package/dist/next.js.map +1 -0
- package/dist/routes-B7wN4sby.d.ts +130 -0
- package/dist/routes-CMzB52P6.d.cts +130 -0
- package/dist/situations.cjs +15 -0
- package/dist/situations.cjs.map +1 -0
- package/dist/situations.d.cts +607 -0
- package/dist/situations.d.ts +607 -0
- package/dist/situations.js +15 -0
- package/dist/situations.js.map +1 -0
- package/dist/subscriptions-B6Lin6i_.d.cts +775 -0
- package/dist/subscriptions-B6Lin6i_.d.ts +775 -0
- package/dist/testing.cjs +1488 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +331 -0
- package/dist/testing.d.ts +331 -0
- package/dist/testing.js +1488 -0
- package/dist/testing.js.map +1 -0
- package/package.json +158 -4
package/dist/testing.cjs
ADDED
|
@@ -0,0 +1,1488 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class;
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
var _chunk4JSJOX35cjs = require('./chunk-4JSJOX35.cjs');
|
|
8
|
+
|
|
9
|
+
// src/testing.ts
|
|
10
|
+
var ACCESS_ATTEMPTS = 5;
|
|
11
|
+
var NOTHING = {
|
|
12
|
+
subscription_id: null,
|
|
13
|
+
access: false,
|
|
14
|
+
status: "none",
|
|
15
|
+
paused: false,
|
|
16
|
+
end_reason: null,
|
|
17
|
+
late_reason: null,
|
|
18
|
+
stop_reason: null,
|
|
19
|
+
payment_status: "none",
|
|
20
|
+
blocked_since: null,
|
|
21
|
+
blocked_reason: null,
|
|
22
|
+
subscribed_since: null,
|
|
23
|
+
first_subscribed_at: null,
|
|
24
|
+
current_period_end: null,
|
|
25
|
+
cancelled_at: null,
|
|
26
|
+
access_until: null,
|
|
27
|
+
next_charge_at: null,
|
|
28
|
+
next_retry_at: null,
|
|
29
|
+
retry_deadline: null
|
|
30
|
+
};
|
|
31
|
+
var REVALIDATE_MIN_S = 10;
|
|
32
|
+
var REVALIDATE_MAX_S = 300;
|
|
33
|
+
var OUTAGE = {
|
|
34
|
+
status: 503,
|
|
35
|
+
code: "unavailable",
|
|
36
|
+
message: "Mesub is unavailable."
|
|
37
|
+
};
|
|
38
|
+
var FAKE_WEBHOOK_SECRET = "whsec_bWVzdWItZmFrZS13ZWJob29rLWtleS0x";
|
|
39
|
+
var FAKE_WALLET = "11111111111111111111111111111111";
|
|
40
|
+
var TEST_EVENT_ID = "evt_test";
|
|
41
|
+
var EVENTS_WINDOW_MS = 30 * 24 * 3600 * 1e3;
|
|
42
|
+
var EVENTS_LIMIT = 25;
|
|
43
|
+
var EVENTS_LIMIT_MAX = 100;
|
|
44
|
+
var EVENT_TYPES = [
|
|
45
|
+
"subscription.created",
|
|
46
|
+
"subscription.renewal_upcoming",
|
|
47
|
+
"subscription.renewed",
|
|
48
|
+
"subscription.payment_failed",
|
|
49
|
+
"subscription.stopped",
|
|
50
|
+
"subscription.cancelled",
|
|
51
|
+
"subscription.resumed",
|
|
52
|
+
"subscription.ended",
|
|
53
|
+
"subscription.expired",
|
|
54
|
+
"subscription.access_granted",
|
|
55
|
+
"subscription.access_revoked",
|
|
56
|
+
"subscription.paused",
|
|
57
|
+
"subscription.unpaused"
|
|
58
|
+
];
|
|
59
|
+
var RESOURCE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
60
|
+
var REQUEST_ID_HEADER = "Mesub-Request-Id";
|
|
61
|
+
var USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
|
|
62
|
+
var PERIOD_MS = 30 * 24 * 3600 * 1e3;
|
|
63
|
+
var DAY_MS = 24 * 3600 * 1e3;
|
|
64
|
+
var TEST_SUBSCRIPTION = {
|
|
65
|
+
id: "sub_test",
|
|
66
|
+
status: "active",
|
|
67
|
+
paused: false,
|
|
68
|
+
end_reason: null,
|
|
69
|
+
late_reason: null,
|
|
70
|
+
stop_reason: null,
|
|
71
|
+
access: true,
|
|
72
|
+
payment_status: "paid",
|
|
73
|
+
blocked_since: null,
|
|
74
|
+
blocked_reason: null,
|
|
75
|
+
plan: "pro",
|
|
76
|
+
wallet: FAKE_WALLET,
|
|
77
|
+
email: "subscriber@example.com",
|
|
78
|
+
external_id: "user_123",
|
|
79
|
+
current_period_start: "2026-01-01T00:00:00.000Z",
|
|
80
|
+
current_period_end: "2026-01-31T00:00:00.000Z",
|
|
81
|
+
next_charge_at: "2026-01-31T00:00:00.000Z",
|
|
82
|
+
next_retry_at: null,
|
|
83
|
+
retry_deadline: null,
|
|
84
|
+
next_retry_number: null,
|
|
85
|
+
retries_allowed: null,
|
|
86
|
+
access_until: "2026-01-31T00:00:00.000Z",
|
|
87
|
+
closable: false,
|
|
88
|
+
closable_from: null,
|
|
89
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
90
|
+
confirmed_at: "2026-01-01T00:00:00.000Z"
|
|
91
|
+
};
|
|
92
|
+
var FREE_RETRY_WAIT_MS = 10 * 60 * 1e3;
|
|
93
|
+
var FREE_HAND_RETRIES = 3;
|
|
94
|
+
var PAID_RETRY_WAIT_MS = 20 * 60 * 1e3;
|
|
95
|
+
var CLOCK_MARGIN_MS = 2 * 60 * 1e3;
|
|
96
|
+
var TRANSACTION_LIFE_MS = 3e4;
|
|
97
|
+
var REASONS = {
|
|
98
|
+
400: "Bad Request",
|
|
99
|
+
401: "Unauthorized",
|
|
100
|
+
403: "Forbidden",
|
|
101
|
+
404: "Not Found",
|
|
102
|
+
409: "Conflict",
|
|
103
|
+
429: "Too Many Requests",
|
|
104
|
+
500: "Internal Server Error",
|
|
105
|
+
503: "Service Unavailable"
|
|
106
|
+
};
|
|
107
|
+
var FakeMesub = (_class = class {
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
/** Every call received, oldest first. */
|
|
112
|
+
__init() {this.requests = []}
|
|
113
|
+
/** Hand it to `new Mesub({ fetch })`, or use `client()`. */
|
|
114
|
+
|
|
115
|
+
#plans;
|
|
116
|
+
#planList;
|
|
117
|
+
#answers = /* @__PURE__ */ new Map();
|
|
118
|
+
/** The ends `grantLastPeriod` and `endPlan` gave a plan, by slug: they win over `plans`. */
|
|
119
|
+
#ends = /* @__PURE__ */ new Map();
|
|
120
|
+
#subscriptions = [];
|
|
121
|
+
/** The subscriptions a cancel, resume or close transaction was built for, by id. */
|
|
122
|
+
#built = /* @__PURE__ */ new Map();
|
|
123
|
+
/** The rows ended with their plan whose close landed: nothing of them is left on chain. */
|
|
124
|
+
#gone = /* @__PURE__ */ new Set();
|
|
125
|
+
/** The attempts `setAttempts` gave each subscription, newest first, by id. */
|
|
126
|
+
#attempts = /* @__PURE__ */ new Map();
|
|
127
|
+
#attemptsRoute;
|
|
128
|
+
#revalidateAfter;
|
|
129
|
+
#attemptIds = 0;
|
|
130
|
+
#eventIds = 0;
|
|
131
|
+
/** The last `sequence` given to each subscription's events, by id. */
|
|
132
|
+
#sequences = /* @__PURE__ */ new Map();
|
|
133
|
+
#accessEvents;
|
|
134
|
+
#requestIds = 0;
|
|
135
|
+
/** Every event raised or added, as `/v1/events` serves it. */
|
|
136
|
+
#events = [];
|
|
137
|
+
#eventsSince;
|
|
138
|
+
#failure = null;
|
|
139
|
+
#ids = 0;
|
|
140
|
+
/** The made-up subscriptions of `webhook` and `addEvent`, apart: they take no `sub_fake_` id. */
|
|
141
|
+
#madeUpIds = 0;
|
|
142
|
+
#deliveryIds = 0;
|
|
143
|
+
#tier;
|
|
144
|
+
/** The hand retries fired on each late subscription, and when the last one was, by id. */
|
|
145
|
+
#handRetries = /* @__PURE__ */ new Map();
|
|
146
|
+
constructor(options = {}) {;_class.prototype.__init.call(this);
|
|
147
|
+
this.baseUrl = (_nullishCoalesce(options.baseUrl, () => ( "https://api.mesub.test"))).replace(/\/+$/, "");
|
|
148
|
+
this.apiKey = _nullishCoalesce(options.apiKey, () => ( "SUB_test_fake"));
|
|
149
|
+
const plans = _optionalChain([options, 'access', _ => _.plans, 'optionalAccess', _2 => _2.map, 'call', _3 => _3(
|
|
150
|
+
(plan) => typeof plan === "string" ? fakePlan(plan) : fakePlan(plan.slug, plan)
|
|
151
|
+
)]);
|
|
152
|
+
this.#plans = plans ? new Set(plans.map((plan) => plan.slug)) : null;
|
|
153
|
+
this.#planList = (_nullishCoalesce(plans, () => ( []))).sort((a, b) => a.slug.localeCompare(b.slug));
|
|
154
|
+
this.webhookSecret = _nullishCoalesce(options.webhookSecret, () => ( FAKE_WEBHOOK_SECRET));
|
|
155
|
+
this.#attemptsRoute = _nullishCoalesce(options.attemptsRoute, () => ( true));
|
|
156
|
+
this.#revalidateAfter = options.revalidateAfter;
|
|
157
|
+
this.#accessEvents = _nullishCoalesce(options.accessEvents, () => ( false));
|
|
158
|
+
this.#tier = _nullishCoalesce(options.tier, () => ( "dev"));
|
|
159
|
+
this.#eventsSince = new Date(
|
|
160
|
+
_nullishCoalesce(options.eventsSince, () => ( Date.now() - EVENTS_WINDOW_MS))
|
|
161
|
+
).toISOString();
|
|
162
|
+
this.fetch = (input, init) => this.#handle(input, init);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A client wired to this fake: its key, base URL and fetch, and no retry
|
|
166
|
+
* unless asked, so a failure shows at once. Any other option is passed on.
|
|
167
|
+
*/
|
|
168
|
+
client(options = {}) {
|
|
169
|
+
return new (0, _chunk4JSJOX35cjs.Mesub)({
|
|
170
|
+
maxRetries: 0,
|
|
171
|
+
...options,
|
|
172
|
+
apiKey: this.apiKey,
|
|
173
|
+
baseUrl: this.baseUrl,
|
|
174
|
+
fetch: this.fetch,
|
|
175
|
+
webhookSecret: this.webhookSecret
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* That customer has access to that plan: active and paid, unless `fields`
|
|
180
|
+
* say otherwise. The customer is named as `access` names them, and only
|
|
181
|
+
* that way answers: a wallet granted is not found by its external id.
|
|
182
|
+
*/
|
|
183
|
+
grant(customer, plan, fields = {}) {
|
|
184
|
+
return this.setAccess(customer, plan, {
|
|
185
|
+
access: true,
|
|
186
|
+
status: "active",
|
|
187
|
+
payment_status: "paid",
|
|
188
|
+
...fields
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** That customer has no access to that plan: `status` says why, `none` by default. */
|
|
192
|
+
deny(customer, plan, fields = {}) {
|
|
193
|
+
return this.setAccess(customer, plan, { ...fields, access: false });
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* That customer is in the last period of a plan that ends at `endsAt`:
|
|
197
|
+
* active and paid, access until the end and no charge ahead, unless
|
|
198
|
+
* `fields` say otherwise. The plan gets that `ends_at`, and from then on
|
|
199
|
+
* the fake refuses everyone on it, as Mesub does, whatever `status` reads.
|
|
200
|
+
*/
|
|
201
|
+
grantLastPeriod(customer, plan, endsAt, fields = {}) {
|
|
202
|
+
const end = new Date(endsAt).toISOString();
|
|
203
|
+
this.#ends.set(plan, end);
|
|
204
|
+
return this.grant(customer, plan, { access_until: end, ...fields });
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* That plan reached its end, at `at` or now: it takes nobody new, and
|
|
208
|
+
* what the fake holds on it is ended as Mesub ends it, `plan_ended` for a
|
|
209
|
+
* running or late one, `cancelled` for one that had cancelled. Before
|
|
210
|
+
* this is called, a plan past its `ends_at` already refuses everyone,
|
|
211
|
+
* with `status` unchanged: the minutes Mesub takes to end the rows.
|
|
212
|
+
*/
|
|
213
|
+
endPlan(plan, at = /* @__PURE__ */ new Date()) {
|
|
214
|
+
this.#ends.set(plan, new Date(at).toISOString());
|
|
215
|
+
for (const [key, answer] of this.#answers) {
|
|
216
|
+
if (answer.plan === plan) this.#answers.set(key, { ...answer, ...endedBy(answer) });
|
|
217
|
+
}
|
|
218
|
+
for (const subscription of this.#subscriptions) {
|
|
219
|
+
if (subscription.plan !== plan) continue;
|
|
220
|
+
const ended = endedBy(subscription);
|
|
221
|
+
Object.assign(subscription, ended, "status" in ended && NO_RETRY);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Parks that subscription's seat, as Mesub does when a project holds more
|
|
226
|
+
* subscribers than its tier allows: `paused`, no next charge, access and
|
|
227
|
+
* status unchanged (the fake moves no clock, so the paid period never
|
|
228
|
+
* runs out). With `accessEvents`, raises `subscription.paused`
|
|
229
|
+
* (Mesub-io/backend#509), sent behind the same flag. Throws for an id the
|
|
230
|
+
* fake does not hold.
|
|
231
|
+
*/
|
|
232
|
+
park(id) {
|
|
233
|
+
return this.#seat(id, true);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Brings a parked seat back: not `paused`, charged again at the end of
|
|
237
|
+
* its period. With `accessEvents`, raises `subscription.unpaused`.
|
|
238
|
+
*/
|
|
239
|
+
unpark(id) {
|
|
240
|
+
return this.#seat(id, false);
|
|
241
|
+
}
|
|
242
|
+
#seat(id, paused) {
|
|
243
|
+
const subscription = this.#subscriptions.find((each) => each.id === id);
|
|
244
|
+
if (!subscription) throw new Error(`The fake holds no subscription ${id}.`);
|
|
245
|
+
if (subscription.paused === paused) return this.#view(subscription);
|
|
246
|
+
const before = this.#view(subscription).access;
|
|
247
|
+
const next_charge_at = paused ? null : subscription.current_period_end;
|
|
248
|
+
Object.assign(subscription, { paused, next_charge_at });
|
|
249
|
+
this.#answer(subscription, { paused, next_charge_at });
|
|
250
|
+
if (this.#accessEvents) {
|
|
251
|
+
this.#raise(
|
|
252
|
+
paused ? "subscription.paused" : "subscription.unpaused",
|
|
253
|
+
subscription,
|
|
254
|
+
before
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return this.#view(subscription);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* What `/v1/access` answers that customer on that plan, on top of the
|
|
261
|
+
* answer for a customer with nothing. `wallet` defaults to the wallet
|
|
262
|
+
* asked about, and to null for an external id or an email.
|
|
263
|
+
*/
|
|
264
|
+
setAccess(customer, plan, fields) {
|
|
265
|
+
const asked = _chunk4JSJOX35cjs.customerOf.call(void 0, customer);
|
|
266
|
+
const answer = { ...nothing(asked, plan), ...fields, plan };
|
|
267
|
+
this.#answers.set(keyOf(asked, plan), answer);
|
|
268
|
+
return this.#fresh(answer);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A subscription `retrieve` and `list` answer, active by default, dated
|
|
272
|
+
* as Mesub dates a row of that `status` unless `fields` say otherwise:
|
|
273
|
+
* active, paid for a period ending in 30 days and charged then; unpaid,
|
|
274
|
+
* that period over, late, with access and a retry tomorrow, or on `free`
|
|
275
|
+
* no access and hand retries until `retry_deadline`; cancelled, access to
|
|
276
|
+
* the end of the period; stopped (`out_of_retries`) or ended (`closed`),
|
|
277
|
+
* no access. Its access answer is left as it is: set it with `grant`.
|
|
278
|
+
* Throws for a wallet Mesub would refuse. Its plan may be one `plans`
|
|
279
|
+
* leaves out: Mesub serves the rows of a plan it no longer has.
|
|
280
|
+
*/
|
|
281
|
+
addSubscription(fields) {
|
|
282
|
+
if (!BASE58_ADDRESS.test(fields.wallet)) {
|
|
283
|
+
throw new Error(`${fields.wallet} is not a wallet: a base58 Solana address.`);
|
|
284
|
+
}
|
|
285
|
+
const subscription = this.#subscription(fields);
|
|
286
|
+
this.#subscriptions.push(subscription);
|
|
287
|
+
return subscription;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The pull attempts of that subscription, every one it ever had: what
|
|
291
|
+
* `subscriptions.attempts` answers for it, with their `paid` total, and
|
|
292
|
+
* the five newest through `/v1/access` with `attempts`, whose answer
|
|
293
|
+
* becomes about that subscription. Each is a paid one of 9.99 USDC now,
|
|
294
|
+
* priced 9.99 dollars, not a retry, unless told otherwise; one given
|
|
295
|
+
* another `amount` and no `amount_usd` is not priced. Newest first.
|
|
296
|
+
* Throws for an id the fake does not hold.
|
|
297
|
+
*/
|
|
298
|
+
setAttempts(id, attempts) {
|
|
299
|
+
const subscription = this.#subscriptions.find((each) => each.id === id);
|
|
300
|
+
if (!subscription) throw new Error(`The fake holds no subscription ${id}.`);
|
|
301
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
302
|
+
const filled = attempts.map((attempt) => ({
|
|
303
|
+
id: `att_fake_${this.#attemptIds += 1}`,
|
|
304
|
+
attempted_at: now,
|
|
305
|
+
outcome: "paid",
|
|
306
|
+
reason: null,
|
|
307
|
+
amount: "9990000",
|
|
308
|
+
// The default amount's worth only: no price is guessed for another.
|
|
309
|
+
amount_usd: attempt.amount === void 0 ? "9.99" : null,
|
|
310
|
+
// A mint that withholds nothing: the receiver got it all.
|
|
311
|
+
amount_received: _nullishCoalesce(attempt.amount, () => ( "9990000")),
|
|
312
|
+
signature: "fake_signature",
|
|
313
|
+
retry: false,
|
|
314
|
+
retry_number: null,
|
|
315
|
+
retries_allowed: null,
|
|
316
|
+
period_start: null,
|
|
317
|
+
...attempt
|
|
318
|
+
})).sort((a, b) => Date.parse(b.attempted_at) - Date.parse(a.attempted_at));
|
|
319
|
+
this.#attempts.set(id, filled);
|
|
320
|
+
this.#answer(subscription, {
|
|
321
|
+
subscribed_since: subscription.confirmed_at,
|
|
322
|
+
attempts: filled.slice(0, ACCESS_ATTEMPTS).map(({ outcome, reason, amount, attempted_at, signature }) => ({
|
|
323
|
+
outcome,
|
|
324
|
+
reason,
|
|
325
|
+
amount,
|
|
326
|
+
attempted_at,
|
|
327
|
+
signature
|
|
328
|
+
}))
|
|
329
|
+
});
|
|
330
|
+
return filled;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* A webhook of that type, as Mesub posts it to an endpoint: the event's
|
|
334
|
+
* body (Mesub-io/backend#482), on a made-up subscription and detail
|
|
335
|
+
* unless `fields` say otherwise, the subscription the fake holds under
|
|
336
|
+
* `fields.subscription.id` when it holds one, signed with
|
|
337
|
+
* `webhookSecret`. The `test` event is Mesub's test delivery: `test:
|
|
338
|
+
* true`, `evt_test`, on its sample subscription (`sub_test`,
|
|
339
|
+
* `user_123`). Post it to your own handler, or hand it to
|
|
340
|
+
* `webhooks.verify` of a `client()`. Nothing is added to the fake.
|
|
341
|
+
*/
|
|
342
|
+
async webhook(type, fields = {}) {
|
|
343
|
+
const now = /* @__PURE__ */ new Date();
|
|
344
|
+
const test = type === "test";
|
|
345
|
+
const data = test ? { ...TEST_SUBSCRIPTION, ...fields.subscription } : withClosing(this.#madeUp(type, fields.subscription));
|
|
346
|
+
const sequence = fields.sequence === void 0 ? test ? 1 : this.#nextSequence(data.id) : fields.sequence;
|
|
347
|
+
const payload = {
|
|
348
|
+
id: _nullishCoalesce(fields.id, () => ( (test ? TEST_EVENT_ID : this.#nextEventId()))),
|
|
349
|
+
object: "event",
|
|
350
|
+
type,
|
|
351
|
+
created_at: _nullishCoalesce(fields.created_at, () => ( now.toISOString())),
|
|
352
|
+
network: "mainnet-beta",
|
|
353
|
+
api_version: _chunk4JSJOX35cjs.API_VERSION,
|
|
354
|
+
...sequence !== null && { sequence },
|
|
355
|
+
data,
|
|
356
|
+
detail: _nullishCoalesce(fields.detail, () => ( detailOf(type, now, data))),
|
|
357
|
+
...test && { test: true }
|
|
358
|
+
};
|
|
359
|
+
return signWebhook(payload, {
|
|
360
|
+
secret: this.webhookSecret,
|
|
361
|
+
id: _nullishCoalesce(fields.delivery_id, () => ( `msg_fake_${this.#deliveryIds += 1}`)),
|
|
362
|
+
...fields.timestamp !== void 0 && { timestamp: fields.timestamp }
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The subscription an event carries: the one the fake holds under
|
|
367
|
+
* `fields.id` when it holds one, else a made-up one, active and paid on
|
|
368
|
+
* `pro` unless the type says otherwise; `fields` on top.
|
|
369
|
+
*/
|
|
370
|
+
#madeUp(type, fields = {}) {
|
|
371
|
+
const held = this.#subscriptions.find((each) => each.id === fields.id);
|
|
372
|
+
const base = held ? this.#view(held) : {
|
|
373
|
+
id: _nullishCoalesce(fields.id, () => ( `sub_fake_event_${this.#madeUpIds += 1}`)),
|
|
374
|
+
wallet: FAKE_WALLET,
|
|
375
|
+
plan: "pro",
|
|
376
|
+
...subscriptionOf(type)
|
|
377
|
+
};
|
|
378
|
+
return this.#subscription({ ...base, ...fields });
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* An event `/v1/events` serves from now on, as Mesub would have raised
|
|
382
|
+
* it: a made-up subscription and detail unless `fields` say otherwise,
|
|
383
|
+
* the subscription the fake holds under `fields.subscription.id` when it
|
|
384
|
+
* holds one. Nothing else changes: what it says happened is not done.
|
|
385
|
+
*/
|
|
386
|
+
addEvent(type, fields = {}) {
|
|
387
|
+
const now = /* @__PURE__ */ new Date();
|
|
388
|
+
const data = withClosing(this.#madeUp(type, fields.subscription));
|
|
389
|
+
const sequence = fields.sequence === void 0 ? this.#nextSequence(data.id) : fields.sequence;
|
|
390
|
+
const event = {
|
|
391
|
+
id: _nullishCoalesce(fields.id, () => ( this.#nextEventId())),
|
|
392
|
+
object: "event",
|
|
393
|
+
type,
|
|
394
|
+
created_at: _nullishCoalesce(fields.created_at, () => ( now.toISOString())),
|
|
395
|
+
network: "mainnet-beta",
|
|
396
|
+
api_version: _chunk4JSJOX35cjs.API_VERSION,
|
|
397
|
+
...sequence !== null && { sequence },
|
|
398
|
+
data,
|
|
399
|
+
detail: _nullishCoalesce(fields.detail, () => ( detailOf(type, now, data)))
|
|
400
|
+
};
|
|
401
|
+
this.#events.push(event);
|
|
402
|
+
return structuredClone(event);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Every call answers that error from now on, `'outage'` a 503, until
|
|
406
|
+
* `fail(null)`.
|
|
407
|
+
*/
|
|
408
|
+
fail(failure) {
|
|
409
|
+
this.#failure = failure === "outage" ? OUTAGE : failure;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Forgets every answer, subscription, failure and request, and starts
|
|
413
|
+
* every id over: the same test makes the same ids, whatever ran before.
|
|
414
|
+
*/
|
|
415
|
+
reset() {
|
|
416
|
+
this.#answers.clear();
|
|
417
|
+
this.#ends.clear();
|
|
418
|
+
this.#subscriptions = [];
|
|
419
|
+
this.#built.clear();
|
|
420
|
+
this.#gone.clear();
|
|
421
|
+
this.#attempts.clear();
|
|
422
|
+
this.#events = [];
|
|
423
|
+
this.#sequences.clear();
|
|
424
|
+
this.#handRetries.clear();
|
|
425
|
+
this.#failure = null;
|
|
426
|
+
this.requests.length = 0;
|
|
427
|
+
this.#ids = 0;
|
|
428
|
+
this.#madeUpIds = 0;
|
|
429
|
+
this.#eventIds = 0;
|
|
430
|
+
this.#attemptIds = 0;
|
|
431
|
+
this.#requestIds = 0;
|
|
432
|
+
this.#deliveryIds = 0;
|
|
433
|
+
}
|
|
434
|
+
/** A subscription with every field, dated as Mesub dates one of its `status`. */
|
|
435
|
+
#subscription(fields) {
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
438
|
+
return {
|
|
439
|
+
id: _nullishCoalesce(fields.id, () => ( this.#nextId())),
|
|
440
|
+
status: "active",
|
|
441
|
+
paused: false,
|
|
442
|
+
end_reason: null,
|
|
443
|
+
late_reason: null,
|
|
444
|
+
stop_reason: null,
|
|
445
|
+
blocked_since: null,
|
|
446
|
+
blocked_reason: null,
|
|
447
|
+
email: null,
|
|
448
|
+
external_id: null,
|
|
449
|
+
next_retry_at: null,
|
|
450
|
+
retry_deadline: null,
|
|
451
|
+
next_retry_number: null,
|
|
452
|
+
retries_allowed: null,
|
|
453
|
+
closable: false,
|
|
454
|
+
closable_from: null,
|
|
455
|
+
created_at: iso(now),
|
|
456
|
+
confirmed_at: iso(now),
|
|
457
|
+
...datesOf(_nullishCoalesce(fields.status, () => ( "active")), this.#tier, now),
|
|
458
|
+
...fields
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* An answer as `/v1/access` serves it now: bounded by its plan's end, and
|
|
463
|
+
* fresh for as long as Mesub keeps it, unless a test said otherwise.
|
|
464
|
+
*/
|
|
465
|
+
#fresh(answer) {
|
|
466
|
+
const end = this.#endOf(answer.plan);
|
|
467
|
+
const served = bounded(answer, end);
|
|
468
|
+
const closes = closingOf(served);
|
|
469
|
+
const closed = {
|
|
470
|
+
...served,
|
|
471
|
+
closable: _nullishCoalesce(served.closable, () => ( closes.closable)),
|
|
472
|
+
closable_from: served.closable_from === void 0 ? closes.closable_from : served.closable_from
|
|
473
|
+
};
|
|
474
|
+
return {
|
|
475
|
+
...closed,
|
|
476
|
+
revalidate_after: _nullishCoalesce(_nullishCoalesce(served.revalidate_after, () => ( this.#revalidateAfter)), () => ( revalidateAfter(closed, end)))
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* A subscription as Mesub serves it now: bounded by its plan's end, and
|
|
481
|
+
* `closable` as the fake's close would take it.
|
|
482
|
+
*/
|
|
483
|
+
#view(subscription) {
|
|
484
|
+
const served = bounded(subscription, this.#endOf(subscription.plan));
|
|
485
|
+
return { ...served, ...closingOf(served, this.#gone.has(subscription.id)) };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Records the event Mesub raises for a change to that subscription, as
|
|
489
|
+
* it stands now, then, with `accessEvents`, the access event of the same
|
|
490
|
+
* change when access flipped from `before`: the same `data`, one
|
|
491
|
+
* `sequence` later.
|
|
492
|
+
*/
|
|
493
|
+
#raise(type, subscription, before, detail = {}) {
|
|
494
|
+
const created_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
495
|
+
const data = this.#view(subscription);
|
|
496
|
+
const push = (each, eachDetail) => this.#events.push({
|
|
497
|
+
id: this.#nextEventId(),
|
|
498
|
+
object: "event",
|
|
499
|
+
type: each,
|
|
500
|
+
created_at,
|
|
501
|
+
network: "mainnet-beta",
|
|
502
|
+
api_version: _chunk4JSJOX35cjs.API_VERSION,
|
|
503
|
+
sequence: this.#nextSequence(subscription.id),
|
|
504
|
+
data: structuredClone(data),
|
|
505
|
+
detail: eachDetail
|
|
506
|
+
});
|
|
507
|
+
push(type, detail);
|
|
508
|
+
if (this.#accessEvents && data.access !== before) {
|
|
509
|
+
const flip = data.access ? "subscription.access_granted" : "subscription.access_revoked";
|
|
510
|
+
push(flip, accessDetailOf(data, created_at));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** The next `sequence` of that subscription's events: 1, 2, 3... */
|
|
514
|
+
#nextSequence(id) {
|
|
515
|
+
const next = (_nullishCoalesce(this.#sequences.get(id), () => ( 0))) + 1;
|
|
516
|
+
this.#sequences.set(id, next);
|
|
517
|
+
return next;
|
|
518
|
+
}
|
|
519
|
+
/** A plan's end: the one a test gave it, else its `ends_at` in `plans`. */
|
|
520
|
+
#endOf(slug) {
|
|
521
|
+
if (slug === null) return null;
|
|
522
|
+
return _nullishCoalesce(_nullishCoalesce(this.#ends.get(slug), () => ( _optionalChain([this, 'access', _4 => _4.#planList, 'access', _5 => _5.find, 'call', _6 => _6((each) => each.slug === slug), 'optionalAccess', _7 => _7.ends_at]))), () => ( null));
|
|
523
|
+
}
|
|
524
|
+
/** Whether that plan is past its end, where it takes nobody new. */
|
|
525
|
+
#over(slug) {
|
|
526
|
+
const end = this.#endOf(slug);
|
|
527
|
+
return end !== null && Date.parse(end) <= Date.now();
|
|
528
|
+
}
|
|
529
|
+
/** A plan as served: with its end, and taking nobody new once past it. */
|
|
530
|
+
#served(plan) {
|
|
531
|
+
const ends_at = this.#endOf(plan.slug);
|
|
532
|
+
return { ...plan, ends_at, available: plan.available && !this.#over(plan.slug) };
|
|
533
|
+
}
|
|
534
|
+
#nextId() {
|
|
535
|
+
this.#ids += 1;
|
|
536
|
+
return `sub_fake_${this.#ids}`;
|
|
537
|
+
}
|
|
538
|
+
#nextEventId() {
|
|
539
|
+
this.#eventIds += 1;
|
|
540
|
+
return `evt_fake_${this.#eventIds}`;
|
|
541
|
+
}
|
|
542
|
+
async #handle(input, init = {}) {
|
|
543
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
544
|
+
const base = new URL(this.baseUrl);
|
|
545
|
+
const prefix = base.pathname.replace(/\/+$/, "");
|
|
546
|
+
if (url.origin !== base.origin || !url.pathname.startsWith(`${prefix}/`)) {
|
|
547
|
+
return new Response("Not Found", { status: 404 });
|
|
548
|
+
}
|
|
549
|
+
this.#requestIds += 1;
|
|
550
|
+
const id = `req_fake${String(this.#requestIds).padStart(20, "0")}`;
|
|
551
|
+
return stamped(await this.#route(url, url.pathname.slice(prefix.length), init), id);
|
|
552
|
+
}
|
|
553
|
+
async #route(url, path, init) {
|
|
554
|
+
const method = (_nullishCoalesce(init.method, () => ( "GET"))).toUpperCase();
|
|
555
|
+
const headers = new Headers(init.headers);
|
|
556
|
+
const query = Object.fromEntries(url.searchParams);
|
|
557
|
+
const body = typeof init.body === "string" ? JSON.parse(init.body) : void 0;
|
|
558
|
+
this.requests.push({ method, path, query, headers, body });
|
|
559
|
+
const [scheme, key] = (_nullishCoalesce(headers.get("authorization"), () => ( ""))).split(" ");
|
|
560
|
+
if (_optionalChain([scheme, 'optionalAccess', _8 => _8.toLowerCase, 'call', _9 => _9()]) !== "bearer" || !key) {
|
|
561
|
+
return error(401, "missing_api_key", "Send your API key as a bearer token.");
|
|
562
|
+
}
|
|
563
|
+
if (key !== this.apiKey) {
|
|
564
|
+
return error(401, "invalid_api_key", "That API key is not valid.");
|
|
565
|
+
}
|
|
566
|
+
const version = headers.get(_chunk4JSJOX35cjs.API_VERSION_HEADER);
|
|
567
|
+
if (version !== null && version !== _chunk4JSJOX35cjs.API_VERSION) {
|
|
568
|
+
return error(
|
|
569
|
+
400,
|
|
570
|
+
"unsupported_version",
|
|
571
|
+
`${_chunk4JSJOX35cjs.API_VERSION_HEADER} must be one of the versions this API serves: ${_chunk4JSJOX35cjs.API_VERSION}.`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (this.#failure) {
|
|
575
|
+
const { status, code, message, retryable } = this.#failure;
|
|
576
|
+
const retryAfter = _nullishCoalesce(this.#failure.retryAfter, () => ( waitOf(method, path, status, code, this.#tier)));
|
|
577
|
+
return error(status, code, _nullishCoalesce(message, () => ( `Mesub answered ${status}.`)), {
|
|
578
|
+
...retryable !== void 0 && { retryable },
|
|
579
|
+
...retryAfter !== void 0 && { retryAfter }
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (method === "GET" && path === "/v1/plans") {
|
|
583
|
+
return Response.json({ plans: this.#planList.map((plan) => this.#served(plan)) });
|
|
584
|
+
}
|
|
585
|
+
const slug = _optionalChain([/^\/v1\/plans\/([^/]+)$/, 'access', _10 => _10.exec, 'call', _11 => _11(path), 'optionalAccess', _12 => _12[1]]);
|
|
586
|
+
if (method === "GET" && slug !== void 0) {
|
|
587
|
+
const plan = _nullishCoalesce(this.#planList.find((each) => each.slug === slug), () => ( // No list given: every slug exists, as for access.
|
|
588
|
+
(this.#plans === null ? fakePlan(slug) : void 0)));
|
|
589
|
+
return plan ? Response.json(this.#served(plan)) : error(404, "plan_not_found", `No plan under slug ${slug}`);
|
|
590
|
+
}
|
|
591
|
+
if (method === "GET" && path === "/v1/access") return this.#access(query);
|
|
592
|
+
if (method === "GET" && path === "/v1/events") return this.#eventList(query);
|
|
593
|
+
const eventId = _optionalChain([/^\/v1\/events\/([^/]+)$/, 'access', _13 => _13.exec, 'call', _14 => _14(path), 'optionalAccess', _15 => _15[1]]);
|
|
594
|
+
if (method === "GET" && eventId !== void 0) {
|
|
595
|
+
const event = this.#servedEvents().find(
|
|
596
|
+
(each) => each.id === decodeURIComponent(eventId)
|
|
597
|
+
);
|
|
598
|
+
return event ? Response.json(event) : error(
|
|
599
|
+
404,
|
|
600
|
+
"event_not_found",
|
|
601
|
+
"No event of yours under that id in the last 30 days."
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (method === "GET" && path === "/v1/subscriptions") return this.#list(query);
|
|
605
|
+
if (method === "POST" && path === "/v1/subscriptions") return this.#create(body);
|
|
606
|
+
const [, id, step, confirm] = _nullishCoalesce(/^\/v1\/subscriptions\/([^/]+)(?:\/(submit|transaction|cancel|resume|close|attempts|retry)(\/confirm)?)?$/.exec(
|
|
607
|
+
path
|
|
608
|
+
), () => ( []));
|
|
609
|
+
if (step === "attempts" && (confirm || !this.#attemptsRoute)) {
|
|
610
|
+
return error(404, "not_found", `Cannot ${method} ${path}`);
|
|
611
|
+
}
|
|
612
|
+
if (method === "POST" && step === "submit" && !confirm) {
|
|
613
|
+
const invalid = invalidSubmit(body);
|
|
614
|
+
if (invalid) return invalid;
|
|
615
|
+
}
|
|
616
|
+
if (id !== void 0) {
|
|
617
|
+
const subscription = this.#subscriptions.find((s) => s.id === decodeURIComponent(id));
|
|
618
|
+
if (!subscription) {
|
|
619
|
+
return error(404, "subscription_not_found", "No subscription under that id.");
|
|
620
|
+
}
|
|
621
|
+
if (method === "GET" && !step) return Response.json(this.#view(subscription));
|
|
622
|
+
if (step === "attempts") {
|
|
623
|
+
return method === "GET" ? this.#attemptsOf(subscription, query) : error(404, "not_found", `Cannot ${method} ${path}`);
|
|
624
|
+
}
|
|
625
|
+
if (method === "POST" && step === "submit" && !confirm) {
|
|
626
|
+
return this.#submit(subscription);
|
|
627
|
+
}
|
|
628
|
+
if (step === "transaction") {
|
|
629
|
+
return method === "POST" && !confirm ? this.#transaction(subscription) : error(404, "not_found", `Cannot ${method} ${path}`);
|
|
630
|
+
}
|
|
631
|
+
if (step === "retry") {
|
|
632
|
+
return method === "POST" && !confirm ? this.#retry(subscription) : error(404, "not_found", `Cannot ${method} ${path}`);
|
|
633
|
+
}
|
|
634
|
+
if (method === "POST" && step && step !== "submit") {
|
|
635
|
+
return confirm ? this.#confirm(step, subscription, body) : this.#build(step, subscription);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return error(404, "not_found", `Cannot ${method} ${path}`);
|
|
639
|
+
}
|
|
640
|
+
#access(query) {
|
|
641
|
+
const invalid = invalidContact(query);
|
|
642
|
+
if (invalid) return invalid;
|
|
643
|
+
const asked = askedIn(query);
|
|
644
|
+
if (!asked)
|
|
645
|
+
return error(400, "invalid_request", "Name one of wallet, external_id or email.");
|
|
646
|
+
const { plan } = query;
|
|
647
|
+
const attempts = query["attempts"] === "true";
|
|
648
|
+
if (plan === void 0) {
|
|
649
|
+
const plans = [...this.#answers.entries()].filter(([key]) => key.startsWith(keyOf(asked, ""))).map(([, answer2]) => withAttempts(this.#fresh(answer2), attempts));
|
|
650
|
+
const granted = plans.filter((answer2) => answer2.access);
|
|
651
|
+
return Response.json({
|
|
652
|
+
plans,
|
|
653
|
+
revalidate_after: granted.length ? Math.min(...granted.map((answer2) => answer2.revalidate_after)) : _nullishCoalesce(this.#revalidateAfter, () => ( REVALIDATE_MIN_S))
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
if (this.#plans && !this.#plans.has(plan)) {
|
|
657
|
+
return error(404, "plan_not_found", `No plan under slug ${plan}`);
|
|
658
|
+
}
|
|
659
|
+
const answer = _nullishCoalesce(this.#answers.get(keyOf(asked, plan)), () => ( nothing(asked, plan)));
|
|
660
|
+
return Response.json(withAttempts(this.#fresh(answer), attempts));
|
|
661
|
+
}
|
|
662
|
+
#list(query) {
|
|
663
|
+
const invalid = invalidContact(query);
|
|
664
|
+
if (invalid) return invalid;
|
|
665
|
+
const asked = askedIn(query);
|
|
666
|
+
if (!asked)
|
|
667
|
+
return error(400, "invalid_request", "Name one of wallet, external_id or email.");
|
|
668
|
+
const limit = query["limit"] === void 0 ? 20 : limitOf(query["limit"]);
|
|
669
|
+
if (limit === null) return error(400, "invalid_request", [LIMIT_MESSAGE]);
|
|
670
|
+
const all = [...this.#subscriptions].reverse();
|
|
671
|
+
const after = query["starting_after"];
|
|
672
|
+
const from = after === void 0 ? 0 : all.findIndex((s) => s.id === after) + 1;
|
|
673
|
+
if (after !== void 0 && from === 0) {
|
|
674
|
+
return error(400, "invalid_request", "starting_after is not a subscription of yours.");
|
|
675
|
+
}
|
|
676
|
+
const data = all.slice(from).filter((s) => s.status !== "superseded").filter((s) => s[asked.kind] === asked.value).filter((s) => query["plan"] === void 0 || s.plan === query["plan"]);
|
|
677
|
+
const page = {
|
|
678
|
+
data: data.slice(0, limit).map((s) => this.#view(s)),
|
|
679
|
+
has_more: data.length > limit
|
|
680
|
+
};
|
|
681
|
+
return Response.json(page);
|
|
682
|
+
}
|
|
683
|
+
/** The events Mesub serves now: within the window and from `available_since`, newest first. */
|
|
684
|
+
#servedEvents() {
|
|
685
|
+
const floor = Math.max(Date.now() - EVENTS_WINDOW_MS, Date.parse(this.#eventsSince));
|
|
686
|
+
return this.#events.filter((event) => Date.parse(event.created_at) >= floor).sort(
|
|
687
|
+
(a, b) => Date.parse(b.created_at) - Date.parse(a.created_at) || // Within a millisecond, by `sequence`, highest first, as Mesub.
|
|
688
|
+
(_nullishCoalesce(b.sequence, () => ( 0))) - (_nullishCoalesce(a.sequence, () => ( 0))) || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
#eventList(query) {
|
|
692
|
+
const { type, subscription, starting_after: after } = query;
|
|
693
|
+
const limit = Number(_nullishCoalesce(query["limit"], () => ( EVENTS_LIMIT)));
|
|
694
|
+
const reasons = [
|
|
695
|
+
!Number.isInteger(limit) || limit < 1 || limit > EVENTS_LIMIT_MAX ? `limit must be a whole number from 1 to ${EVENTS_LIMIT_MAX}` : null,
|
|
696
|
+
type !== void 0 && !EVENT_TYPES.includes(type) ? `type is one of ${EVENT_TYPES.join(", ")}` : null,
|
|
697
|
+
subscription !== void 0 && !RESOURCE_ID.test(subscription) ? "subscription must be a subscription id" : null,
|
|
698
|
+
after !== void 0 && !RESOURCE_ID.test(after) ? "starting_after must be an event id" : null
|
|
699
|
+
].filter((reason) => reason !== null);
|
|
700
|
+
if (reasons.length) return error(400, "invalid_request", reasons);
|
|
701
|
+
const all = this.#servedEvents();
|
|
702
|
+
const from = after === void 0 ? 0 : all.findIndex((each) => each.id === after) + 1;
|
|
703
|
+
if (after !== void 0 && from === 0) {
|
|
704
|
+
return error(400, "invalid_request", "starting_after is not an event of yours.");
|
|
705
|
+
}
|
|
706
|
+
const data = all.slice(from).filter((each) => type === void 0 || each.type === type).filter((each) => subscription === void 0 || each.data.id === subscription);
|
|
707
|
+
const page = {
|
|
708
|
+
data: data.slice(0, limit),
|
|
709
|
+
has_more: data.length > limit,
|
|
710
|
+
available_since: this.#eventsSince
|
|
711
|
+
};
|
|
712
|
+
return Response.json(page);
|
|
713
|
+
}
|
|
714
|
+
#attemptsOf(subscription, query) {
|
|
715
|
+
const all = _nullishCoalesce(this.#attempts.get(subscription.id), () => ( []));
|
|
716
|
+
const limit = Number(_nullishCoalesce(query["limit"], () => ( 20)));
|
|
717
|
+
const after = query["starting_after"];
|
|
718
|
+
const from = after === void 0 ? 0 : all.findIndex((each) => each.id === after) + 1;
|
|
719
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
720
|
+
return error(400, "invalid_request", "limit must be a whole number from 1 to 100");
|
|
721
|
+
}
|
|
722
|
+
if (after !== void 0 && from === 0) {
|
|
723
|
+
return error(
|
|
724
|
+
400,
|
|
725
|
+
"invalid_request",
|
|
726
|
+
"starting_after is not an attempt of this subscription."
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
const paid = all.filter((each) => each.outcome === "paid");
|
|
730
|
+
const page = {
|
|
731
|
+
data: all.slice(from, from + limit),
|
|
732
|
+
has_more: all.length > from + limit,
|
|
733
|
+
// Over every attempt, not the page; an amount that is no whole number counts for 0.
|
|
734
|
+
paid: {
|
|
735
|
+
count: paid.length,
|
|
736
|
+
amount: paid.reduce(
|
|
737
|
+
(sum, each) => sum + (/^\d+$/.test(each.amount) ? BigInt(each.amount) : 0n),
|
|
738
|
+
0n
|
|
739
|
+
).toString()
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
return Response.json(page);
|
|
743
|
+
}
|
|
744
|
+
#create(body) {
|
|
745
|
+
const { plan, wallet, email, external_id } = _nullishCoalesce(body, () => ( {}));
|
|
746
|
+
if (typeof plan !== "string" || typeof wallet !== "string") {
|
|
747
|
+
return error(400, "invalid_request", "plan and wallet are required.");
|
|
748
|
+
}
|
|
749
|
+
const invalid = invalidContact({ wallet, email });
|
|
750
|
+
if (invalid) return invalid;
|
|
751
|
+
if (this.#plans && !this.#plans.has(plan)) {
|
|
752
|
+
return error(404, "plan_not_found", `No plan under slug ${plan}`);
|
|
753
|
+
}
|
|
754
|
+
if (this.#over(plan)) return error(409, "plan_ended", "This plan has ended.");
|
|
755
|
+
const contact = {
|
|
756
|
+
email: typeof email === "string" ? email.trim().toLowerCase() : null,
|
|
757
|
+
external_id: typeof external_id === "string" ? external_id.trim() : null
|
|
758
|
+
};
|
|
759
|
+
const held = this.#subscriptions.filter((s) => s.plan === plan && s.wallet === wallet);
|
|
760
|
+
const refusal = liveRefusal(held);
|
|
761
|
+
if (refusal) return refusal;
|
|
762
|
+
const waiting = held.find(
|
|
763
|
+
(s) => s.status === "pending" && holderOf(s) === holderOf(contact)
|
|
764
|
+
);
|
|
765
|
+
if (waiting && contact.email !== null) waiting.email = contact.email;
|
|
766
|
+
const subscription = _nullishCoalesce(waiting, () => ( this.addSubscription({
|
|
767
|
+
plan,
|
|
768
|
+
wallet,
|
|
769
|
+
...contact,
|
|
770
|
+
status: "pending",
|
|
771
|
+
access: false,
|
|
772
|
+
payment_status: "none",
|
|
773
|
+
current_period_start: null,
|
|
774
|
+
confirmed_at: null
|
|
775
|
+
})));
|
|
776
|
+
return Response.json(handedOut(subscription), { status: 201 });
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* A fresh transaction for a subscription still pending, with the same
|
|
780
|
+
* terms message as `create` gave it: a signature held stays good. Refused
|
|
781
|
+
* as Mesub refuses it otherwise.
|
|
782
|
+
*/
|
|
783
|
+
#transaction(subscription) {
|
|
784
|
+
if (subscription.status !== "pending") {
|
|
785
|
+
return error(409, "not_awaiting_signature", "That subscription awaits no signature.");
|
|
786
|
+
}
|
|
787
|
+
if (this.#over(subscription.plan)) return error(409, "plan_ended", "This plan has ended.");
|
|
788
|
+
const refusal = liveRefusal(
|
|
789
|
+
this.#subscriptions.filter(
|
|
790
|
+
(s) => s.plan === subscription.plan && s.wallet === subscription.wallet
|
|
791
|
+
)
|
|
792
|
+
);
|
|
793
|
+
if (refusal) return refusal;
|
|
794
|
+
return Response.json(handedOut(subscription), { status: 201 });
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Lands at once: the subscription turns active and its customer is granted
|
|
798
|
+
* the plan. The other checkouts waiting on that wallet expire, as on Mesub.
|
|
799
|
+
*/
|
|
800
|
+
#submit(subscription) {
|
|
801
|
+
if (subscription.status !== "pending") {
|
|
802
|
+
return error(409, "not_awaiting_signature", "That subscription awaits no signature.");
|
|
803
|
+
}
|
|
804
|
+
if (this.#over(subscription.plan)) return error(409, "plan_ended", "This plan has ended.");
|
|
805
|
+
for (const other of this.#subscriptions) {
|
|
806
|
+
const beside = other.plan === subscription.plan && other.wallet === subscription.wallet;
|
|
807
|
+
if (beside && other !== subscription && other.status === "pending") {
|
|
808
|
+
other.status = "expired";
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const now = /* @__PURE__ */ new Date();
|
|
812
|
+
const end = new Date(now.getTime() + PERIOD_MS).toISOString();
|
|
813
|
+
const dates = bounded(
|
|
814
|
+
{ access: true, access_until: end, next_charge_at: end, next_retry_at: null },
|
|
815
|
+
this.#endOf(subscription.plan)
|
|
816
|
+
);
|
|
817
|
+
Object.assign(subscription, {
|
|
818
|
+
status: "active",
|
|
819
|
+
payment_status: "paid",
|
|
820
|
+
current_period_start: now.toISOString(),
|
|
821
|
+
current_period_end: end,
|
|
822
|
+
confirmed_at: now.toISOString(),
|
|
823
|
+
...dates
|
|
824
|
+
});
|
|
825
|
+
const plan = _nullishCoalesce(subscription.plan, () => ( ""));
|
|
826
|
+
const fields = {
|
|
827
|
+
wallet: subscription.wallet,
|
|
828
|
+
subscription_id: subscription.id,
|
|
829
|
+
subscribed_since: now.toISOString(),
|
|
830
|
+
first_subscribed_at: now.toISOString(),
|
|
831
|
+
current_period_end: end,
|
|
832
|
+
access_until: dates.access_until,
|
|
833
|
+
next_charge_at: dates.next_charge_at
|
|
834
|
+
};
|
|
835
|
+
this.grant({ wallet: subscription.wallet }, plan, fields);
|
|
836
|
+
if (subscription.external_id) {
|
|
837
|
+
this.grant({ external_id: subscription.external_id }, plan, fields);
|
|
838
|
+
}
|
|
839
|
+
if (subscription.email) this.grant({ email: subscription.email }, plan, fields);
|
|
840
|
+
const terms = this.#planList.find((each) => each.slug === subscription.plan);
|
|
841
|
+
this.#raise("subscription.created", subscription, false, {
|
|
842
|
+
first_payment: {
|
|
843
|
+
amount: _nullishCoalesce(_optionalChain([terms, 'optionalAccess', _16 => _16.amount]), () => ( "9990000")),
|
|
844
|
+
mint: _nullishCoalesce(_optionalChain([terms, 'optionalAccess', _17 => _17.mint]), () => ( USDC)),
|
|
845
|
+
signature: "fake_signature"
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
return Response.json({ subscription: this.#view(subscription) }, { status: 201 });
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* A hand retry, refused as Mesub's `RetryNowService` refuses it from what
|
|
852
|
+
* the fake holds, in its order: a parked late seat, a row not late, a
|
|
853
|
+
* plan past its end, the wait since the last one (by `tier`, with
|
|
854
|
+
* `Retry-After`), and on Free a `retry_deadline` passed or the three
|
|
855
|
+
* retries spent. Taken, it pulls nothing and the row stays `unpaid`: the
|
|
856
|
+
* fake counts it as one more that failed. What only the chain or a race
|
|
857
|
+
* decides (`insufficient_balance`, `retry_cannot_succeed`,
|
|
858
|
+
* `pull_running`, `subscription_changed`) is answered with `fail()`.
|
|
859
|
+
*/
|
|
860
|
+
#retry(subscription) {
|
|
861
|
+
if (subscription.status === "unpaid" && subscription.paused) {
|
|
862
|
+
return error(
|
|
863
|
+
409,
|
|
864
|
+
"subscription_paused",
|
|
865
|
+
"This subscription is paused over the merchant's tier limit, so it cannot be retried."
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
if (subscription.status !== "unpaid") {
|
|
869
|
+
return error(
|
|
870
|
+
409,
|
|
871
|
+
"subscription_not_late",
|
|
872
|
+
"This subscription is not behind on its payment."
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
if (this.#over(subscription.plan)) {
|
|
876
|
+
return error(
|
|
877
|
+
409,
|
|
878
|
+
"plan_ended",
|
|
879
|
+
"This plan has ended, so this payment can no longer be collected."
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
const now = Date.now();
|
|
883
|
+
const free = this.#tier === "free";
|
|
884
|
+
const fired = _nullishCoalesce(this.#handRetries.get(subscription.id), () => ( { count: 0, at: 0 }));
|
|
885
|
+
const openAt = fired.count === 0 ? 0 : fired.at + (free ? FREE_RETRY_WAIT_MS : PAID_RETRY_WAIT_MS * 2 ** (fired.count - 1));
|
|
886
|
+
if (openAt > now) {
|
|
887
|
+
const minutes = Math.ceil((openAt - now) / 6e4);
|
|
888
|
+
return error(
|
|
889
|
+
409,
|
|
890
|
+
"retry_too_soon",
|
|
891
|
+
`Try again in ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`,
|
|
892
|
+
{ retryAfter: Math.ceil((openAt - now) / 1e3) }
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
if (free) {
|
|
896
|
+
const deadline = subscription.retry_deadline;
|
|
897
|
+
if (deadline !== null && Date.parse(deadline) <= now) {
|
|
898
|
+
return error(
|
|
899
|
+
409,
|
|
900
|
+
"retry_deadline_passed",
|
|
901
|
+
"The missed period ends too soon to retry."
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
if (fired.count >= FREE_HAND_RETRIES) {
|
|
905
|
+
return error(409, "retries_spent", "This subscription has no retries left.");
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
this.#handRetries.set(subscription.id, { count: fired.count + 1, at: now });
|
|
909
|
+
return Response.json(this.#view(subscription), { status: 202 });
|
|
910
|
+
}
|
|
911
|
+
/** The transaction of a cancel, resume or close, or Mesub's refusal of it. */
|
|
912
|
+
#build(action, subscription) {
|
|
913
|
+
const refusal = refusalOf(action, subscription, this.#gone.has(subscription.id));
|
|
914
|
+
if (refusal) return refusal;
|
|
915
|
+
const built = _nullishCoalesce(this.#built.get(subscription.id), () => ( /* @__PURE__ */ new Set()));
|
|
916
|
+
this.#built.set(subscription.id, built.add(action));
|
|
917
|
+
const answer = {
|
|
918
|
+
transaction: btoa(`fake ${action} transaction`),
|
|
919
|
+
last_valid_block_height: "0",
|
|
920
|
+
transaction_expires_at: expiresAt()
|
|
921
|
+
};
|
|
922
|
+
return Response.json(answer, { status: 201 });
|
|
923
|
+
}
|
|
924
|
+
/** Lands at once: the subscription moves, and so do its customer's access answers. */
|
|
925
|
+
#confirm(action, subscription, body) {
|
|
926
|
+
const { signature } = _nullishCoalesce(body, () => ( {}));
|
|
927
|
+
if (typeof signature !== "string" || signature === "") {
|
|
928
|
+
return error(400, "invalid_request", "signature must be a transaction signature.");
|
|
929
|
+
}
|
|
930
|
+
const gone = this.#gone.has(subscription.id);
|
|
931
|
+
const done = subscription.status === DONE[action] && !(endedWithPlan(subscription) && !gone);
|
|
932
|
+
const refusal = done ? null : refusalOf(action, subscription, gone);
|
|
933
|
+
if (refusal) return refusal;
|
|
934
|
+
if (!_optionalChain([this, 'access', _18 => _18.#built, 'access', _19 => _19.get, 'call', _20 => _20(subscription.id), 'optionalAccess', _21 => _21.has, 'call', _22 => _22(action)])) {
|
|
935
|
+
return error(400, "nothing_to_confirm", "There is no transaction to confirm yet.");
|
|
936
|
+
}
|
|
937
|
+
if (done) return Response.json({ subscription: this.#view(subscription) }, { status: 201 });
|
|
938
|
+
this.#built.set(subscription.id, /* @__PURE__ */ new Set([action]));
|
|
939
|
+
const before = this.#view(subscription).access;
|
|
940
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
941
|
+
const end = subscription.current_period_end;
|
|
942
|
+
if (action === "close" && endedWithPlan(subscription)) {
|
|
943
|
+
this.#gone.add(subscription.id);
|
|
944
|
+
this.#answer(subscription, {});
|
|
945
|
+
return Response.json({ subscription: this.#view(subscription) }, { status: 201 });
|
|
946
|
+
}
|
|
947
|
+
if (action === "cancel") {
|
|
948
|
+
Object.assign(subscription, {
|
|
949
|
+
status: "cancelled",
|
|
950
|
+
stop_reason: null,
|
|
951
|
+
...NO_BLOCK,
|
|
952
|
+
next_charge_at: null,
|
|
953
|
+
next_retry_at: null,
|
|
954
|
+
retry_deadline: null,
|
|
955
|
+
next_retry_number: null,
|
|
956
|
+
retries_allowed: null,
|
|
957
|
+
access_until: subscription.access ? _nullishCoalesce(subscription.access_until, () => ( end)) : null
|
|
958
|
+
});
|
|
959
|
+
this.#answer(subscription, {
|
|
960
|
+
status: "cancelled",
|
|
961
|
+
stop_reason: null,
|
|
962
|
+
...NO_BLOCK,
|
|
963
|
+
// The access a cancellation keeps, also where no grant dated it.
|
|
964
|
+
access_until: subscription.access_until,
|
|
965
|
+
cancelled_at: now,
|
|
966
|
+
next_charge_at: null,
|
|
967
|
+
next_retry_at: null,
|
|
968
|
+
retry_deadline: null
|
|
969
|
+
});
|
|
970
|
+
} else if (action === "resume") {
|
|
971
|
+
Object.assign(subscription, {
|
|
972
|
+
status: "active",
|
|
973
|
+
next_charge_at: end
|
|
974
|
+
});
|
|
975
|
+
this.#answer(subscription, {
|
|
976
|
+
status: "active",
|
|
977
|
+
cancelled_at: null,
|
|
978
|
+
next_charge_at: end
|
|
979
|
+
});
|
|
980
|
+
} else {
|
|
981
|
+
Object.assign(subscription, {
|
|
982
|
+
status: "ended",
|
|
983
|
+
end_reason: "closed",
|
|
984
|
+
stop_reason: null,
|
|
985
|
+
...NO_BLOCK,
|
|
986
|
+
access: false,
|
|
987
|
+
payment_status: "none",
|
|
988
|
+
access_until: null,
|
|
989
|
+
next_charge_at: null,
|
|
990
|
+
next_retry_at: null,
|
|
991
|
+
retry_deadline: null,
|
|
992
|
+
next_retry_number: null,
|
|
993
|
+
retries_allowed: null
|
|
994
|
+
});
|
|
995
|
+
this.#answer(subscription, {
|
|
996
|
+
access: false,
|
|
997
|
+
status: "ended",
|
|
998
|
+
end_reason: "closed",
|
|
999
|
+
stop_reason: null,
|
|
1000
|
+
...NO_BLOCK,
|
|
1001
|
+
payment_status: "none",
|
|
1002
|
+
access_until: null,
|
|
1003
|
+
next_charge_at: null,
|
|
1004
|
+
next_retry_at: null,
|
|
1005
|
+
retry_deadline: null
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
this.#raise(EVENT_OF[action], subscription, before);
|
|
1009
|
+
return Response.json({ subscription: this.#view(subscription) }, { status: 201 });
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Changes what `/v1/access` answers a subscription's customer, by wallet,
|
|
1013
|
+
* external id and email, on top of what each was answered before.
|
|
1014
|
+
*/
|
|
1015
|
+
#answer(subscription, fields) {
|
|
1016
|
+
const plan = _nullishCoalesce(subscription.plan, () => ( ""));
|
|
1017
|
+
const customers = [{ wallet: subscription.wallet }];
|
|
1018
|
+
if (subscription.external_id) customers.push({ external_id: subscription.external_id });
|
|
1019
|
+
if (subscription.email) customers.push({ email: subscription.email });
|
|
1020
|
+
for (const customer of customers) {
|
|
1021
|
+
const before = this.#answers.get(keyOf(_chunk4JSJOX35cjs.customerOf.call(void 0, customer), plan));
|
|
1022
|
+
this.setAccess(customer, plan, {
|
|
1023
|
+
wallet: subscription.wallet,
|
|
1024
|
+
access: subscription.access,
|
|
1025
|
+
payment_status: subscription.payment_status,
|
|
1026
|
+
...before,
|
|
1027
|
+
subscription_id: subscription.id,
|
|
1028
|
+
// A row whose close landed has nothing left to close; any
|
|
1029
|
+
// other is worked out when served.
|
|
1030
|
+
...this.#gone.has(subscription.id) && { closable: false, closable_from: null },
|
|
1031
|
+
...fields
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}, _class);
|
|
1036
|
+
function revalidateAfter(answer, planEnd) {
|
|
1037
|
+
if (!answer.access) return REVALIDATE_MIN_S;
|
|
1038
|
+
const next = soonest(
|
|
1039
|
+
_nullishCoalesce(_nullishCoalesce(answer.next_retry_at, () => ( answer.next_charge_at)), () => ( soonest(
|
|
1040
|
+
answer.paused ? answer.current_period_end : null,
|
|
1041
|
+
answer.status === "cancelled" ? answer.access_until : null
|
|
1042
|
+
))),
|
|
1043
|
+
planEnd,
|
|
1044
|
+
answer.blocked_since === null ? null : answer.access_until
|
|
1045
|
+
);
|
|
1046
|
+
if (next === null) return REVALIDATE_MAX_S;
|
|
1047
|
+
const seconds = Math.floor((Date.parse(next) - Date.now()) / 1e3);
|
|
1048
|
+
return Math.min(REVALIDATE_MAX_S, Math.max(REVALIDATE_MIN_S, seconds));
|
|
1049
|
+
}
|
|
1050
|
+
function soonest(...dates) {
|
|
1051
|
+
return dates.reduce(
|
|
1052
|
+
(first, date) => date === null || first !== null && Date.parse(first) <= Date.parse(date) ? first : date,
|
|
1053
|
+
null
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
function bounded(row, end) {
|
|
1057
|
+
if (end === null || !row.access) return row;
|
|
1058
|
+
const at = Date.parse(end);
|
|
1059
|
+
const none = { next_charge_at: null, next_retry_at: null };
|
|
1060
|
+
if (at <= Date.now()) return { ...row, ...none, access: false, access_until: null };
|
|
1061
|
+
const ahead = (date) => date !== null && Date.parse(date) < at ? date : null;
|
|
1062
|
+
return {
|
|
1063
|
+
...row,
|
|
1064
|
+
access_until: row.access_until !== null && Date.parse(row.access_until) > at ? end : row.access_until,
|
|
1065
|
+
next_charge_at: ahead(row.next_charge_at),
|
|
1066
|
+
next_retry_at: ahead(row.next_retry_at)
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
function datesOf(status, tier, now) {
|
|
1070
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
1071
|
+
const running = { current_period_start: iso(now), current_period_end: iso(now + PERIOD_MS) };
|
|
1072
|
+
const behind = { current_period_start: iso(now - PERIOD_MS), current_period_end: iso(now) };
|
|
1073
|
+
const nothing2 = {
|
|
1074
|
+
access: false,
|
|
1075
|
+
payment_status: "none",
|
|
1076
|
+
next_charge_at: null,
|
|
1077
|
+
access_until: null
|
|
1078
|
+
};
|
|
1079
|
+
switch (status) {
|
|
1080
|
+
case "active":
|
|
1081
|
+
return {
|
|
1082
|
+
...running,
|
|
1083
|
+
access: true,
|
|
1084
|
+
payment_status: "paid",
|
|
1085
|
+
next_charge_at: running.current_period_end,
|
|
1086
|
+
access_until: running.current_period_end
|
|
1087
|
+
};
|
|
1088
|
+
case "cancelled":
|
|
1089
|
+
return {
|
|
1090
|
+
...running,
|
|
1091
|
+
access: true,
|
|
1092
|
+
payment_status: "paid",
|
|
1093
|
+
next_charge_at: null,
|
|
1094
|
+
access_until: running.current_period_end
|
|
1095
|
+
};
|
|
1096
|
+
case "unpaid": {
|
|
1097
|
+
const late = {
|
|
1098
|
+
...behind,
|
|
1099
|
+
late_reason: "insufficient_balance",
|
|
1100
|
+
payment_status: "late",
|
|
1101
|
+
next_charge_at: null
|
|
1102
|
+
};
|
|
1103
|
+
if (tier === "free") {
|
|
1104
|
+
const deadline = iso(now + PERIOD_MS - CLOCK_MARGIN_MS);
|
|
1105
|
+
return { ...late, access: false, access_until: null, retry_deadline: deadline };
|
|
1106
|
+
}
|
|
1107
|
+
const retry = iso(now + DAY_MS);
|
|
1108
|
+
return {
|
|
1109
|
+
...late,
|
|
1110
|
+
access: true,
|
|
1111
|
+
// Its last retry: access stops there if that one fails too.
|
|
1112
|
+
access_until: retry,
|
|
1113
|
+
next_retry_at: retry,
|
|
1114
|
+
next_retry_number: 1,
|
|
1115
|
+
retries_allowed: 1
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
case "stopped":
|
|
1119
|
+
return { ...behind, ...nothing2, stop_reason: "out_of_retries" };
|
|
1120
|
+
case "ended":
|
|
1121
|
+
return { ...behind, ...nothing2, end_reason: "closed" };
|
|
1122
|
+
case "superseded":
|
|
1123
|
+
return { ...behind, ...nothing2 };
|
|
1124
|
+
default:
|
|
1125
|
+
return {
|
|
1126
|
+
...nothing2,
|
|
1127
|
+
current_period_start: null,
|
|
1128
|
+
current_period_end: null,
|
|
1129
|
+
confirmed_at: null
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
function expiresAt() {
|
|
1134
|
+
return new Date(Date.now() + TRANSACTION_LIFE_MS).toISOString();
|
|
1135
|
+
}
|
|
1136
|
+
function handedOut(subscription) {
|
|
1137
|
+
const zero = "0";
|
|
1138
|
+
return {
|
|
1139
|
+
subscription: { id: subscription.id, status: "pending" },
|
|
1140
|
+
transaction: btoa("fake transaction"),
|
|
1141
|
+
last_valid_block_height: zero,
|
|
1142
|
+
transaction_expires_at: expiresAt(),
|
|
1143
|
+
costs: {
|
|
1144
|
+
rent: { subscription: zero, authority: null, total: zero },
|
|
1145
|
+
fee: { signatures: 2, per_signature: zero, priority: zero, total: zero },
|
|
1146
|
+
total: zero
|
|
1147
|
+
},
|
|
1148
|
+
terms: {
|
|
1149
|
+
message: `Fake Mesub terms: ${subscription.wallet} subscribes to ${subscription.plan}.`,
|
|
1150
|
+
expires_at: new Date(Date.now() + 5 * 6e4).toISOString()
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
var NO_BLOCK = { blocked_since: null, blocked_reason: null };
|
|
1155
|
+
var NO_RETRY = { next_retry_number: null, retries_allowed: null };
|
|
1156
|
+
function endedBy(row) {
|
|
1157
|
+
const running = row.status === "active" || row.status === "unpaid";
|
|
1158
|
+
if (!running && row.status !== "cancelled") return {};
|
|
1159
|
+
return {
|
|
1160
|
+
status: "ended",
|
|
1161
|
+
end_reason: running ? "plan_ended" : "cancelled",
|
|
1162
|
+
late_reason: null,
|
|
1163
|
+
...NO_BLOCK,
|
|
1164
|
+
paused: false,
|
|
1165
|
+
access: false,
|
|
1166
|
+
payment_status: "none",
|
|
1167
|
+
access_until: null,
|
|
1168
|
+
next_charge_at: null,
|
|
1169
|
+
next_retry_at: null,
|
|
1170
|
+
retry_deadline: null
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
var EVENT_OF = {
|
|
1174
|
+
cancel: "subscription.cancelled",
|
|
1175
|
+
resume: "subscription.resumed",
|
|
1176
|
+
close: "subscription.ended"
|
|
1177
|
+
};
|
|
1178
|
+
function closingOf(row, gone = false) {
|
|
1179
|
+
const closable = refusalOf("close", row, gone) === null;
|
|
1180
|
+
const ahead = row.access_until !== null && Date.parse(row.access_until) > Date.now();
|
|
1181
|
+
return {
|
|
1182
|
+
closable,
|
|
1183
|
+
closable_from: !closable && row.status === "cancelled" && ahead ? row.access_until : null
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
function withClosing(subscription) {
|
|
1187
|
+
return { ...subscription, ...closingOf(subscription) };
|
|
1188
|
+
}
|
|
1189
|
+
var DONE = {
|
|
1190
|
+
cancel: "cancelled",
|
|
1191
|
+
resume: "active",
|
|
1192
|
+
close: "ended"
|
|
1193
|
+
};
|
|
1194
|
+
function refusalOf(action, subscription, gone = false) {
|
|
1195
|
+
const { status, access_until } = subscription;
|
|
1196
|
+
const over = access_until === null || Date.parse(access_until) <= Date.now();
|
|
1197
|
+
const cancelled = status === "cancelled" || status === "ended" && subscription.end_reason === "cancelled";
|
|
1198
|
+
if (action === "cancel") {
|
|
1199
|
+
if (status === "cancelled") {
|
|
1200
|
+
return error(409, "subscription_cancelled", "This subscription is cancelled already.");
|
|
1201
|
+
}
|
|
1202
|
+
return ["active", "unpaid", "stopped"].includes(status) ? null : error(409, "subscription_not_active", "This subscription is not running.");
|
|
1203
|
+
}
|
|
1204
|
+
if (action === "resume") {
|
|
1205
|
+
if (status !== "cancelled") {
|
|
1206
|
+
return error(409, "subscription_not_cancelled", "This subscription is not cancelled.");
|
|
1207
|
+
}
|
|
1208
|
+
return over ? error(409, "subscription_ended", "The period paid for is over: subscribe again.") : null;
|
|
1209
|
+
}
|
|
1210
|
+
if (endedWithPlan(subscription)) {
|
|
1211
|
+
return gone ? error(
|
|
1212
|
+
409,
|
|
1213
|
+
"subscription_not_on_chain",
|
|
1214
|
+
"This subscription was closed outside Mesub, so it has ended."
|
|
1215
|
+
) : null;
|
|
1216
|
+
}
|
|
1217
|
+
if (isClosed(subscription)) {
|
|
1218
|
+
return error(409, "subscription_not_on_chain", "This subscription is closed already.");
|
|
1219
|
+
}
|
|
1220
|
+
if (status === "ended" && !cancelled) {
|
|
1221
|
+
return error(
|
|
1222
|
+
409,
|
|
1223
|
+
"subscription_not_on_chain",
|
|
1224
|
+
"This subscription has ended and cannot be closed through Mesub."
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
if (status === "stopped" && subscription.stop_reason === "authority_closed") return null;
|
|
1228
|
+
if (!cancelled) {
|
|
1229
|
+
return error(
|
|
1230
|
+
409,
|
|
1231
|
+
"subscription_not_cancelled",
|
|
1232
|
+
"Cancel this subscription before closing it."
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
return over ? null : error(409, "close_too_early", `This subscription runs until ${access_until}.`);
|
|
1236
|
+
}
|
|
1237
|
+
function liveRefusal(held) {
|
|
1238
|
+
const live = held.find(
|
|
1239
|
+
(s) => ["active", "cancelled", "unpaid"].includes(s.status) || s.status === "ended" && s.end_reason === "cancelled"
|
|
1240
|
+
);
|
|
1241
|
+
const dead = held.find((s) => s.status === "stopped" && s.stop_reason === "authority_closed");
|
|
1242
|
+
if (dead && !live) {
|
|
1243
|
+
return error(
|
|
1244
|
+
409,
|
|
1245
|
+
"subscription_stopped",
|
|
1246
|
+
"Your wallet's approval for this token was closed for good, so this subscription cannot restart. Close it, then subscribe again."
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
if (!live) return null;
|
|
1250
|
+
if (live.status === "active") {
|
|
1251
|
+
return error(409, "already_subscribed", "You are already subscribed to this plan.");
|
|
1252
|
+
}
|
|
1253
|
+
if (live.status === "unpaid") {
|
|
1254
|
+
return error(
|
|
1255
|
+
409,
|
|
1256
|
+
"payment_pending",
|
|
1257
|
+
"You already have a subscription to this plan, and its last payment is late. Open it in your subscriptions to see how to fix it, or cancel it."
|
|
1258
|
+
);
|
|
1259
|
+
}
|
|
1260
|
+
const until = live.status === "cancelled" ? live.access_until : null;
|
|
1261
|
+
return error(
|
|
1262
|
+
409,
|
|
1263
|
+
"subscription_cancelled",
|
|
1264
|
+
until !== null && Date.parse(until) > Date.now() ? `Your subscription to this plan is cancelled but runs until ${until}: resume it instead of subscribing again.` : "Your cancelled subscription to this plan must be closed first: close it, then subscribe again."
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
function holderOf(contact) {
|
|
1268
|
+
if (contact.external_id) return `x:${contact.external_id}`;
|
|
1269
|
+
return contact.email ? `e:${contact.email}` : "-";
|
|
1270
|
+
}
|
|
1271
|
+
var WITH_ITS_PLAN = [
|
|
1272
|
+
"plan_removed",
|
|
1273
|
+
"plan_replaced",
|
|
1274
|
+
"plan_ended"
|
|
1275
|
+
];
|
|
1276
|
+
function endedWithPlan(subscription) {
|
|
1277
|
+
return subscription.status === "ended" && WITH_ITS_PLAN.includes(subscription.end_reason);
|
|
1278
|
+
}
|
|
1279
|
+
function isClosed(subscription) {
|
|
1280
|
+
return subscription.status === "ended" && subscription.end_reason === "closed";
|
|
1281
|
+
}
|
|
1282
|
+
function keyOf(asked, plan) {
|
|
1283
|
+
return `${asked.kind}:${asked.value}
|
|
1284
|
+
${plan}`;
|
|
1285
|
+
}
|
|
1286
|
+
var signedIds = 0;
|
|
1287
|
+
async function signWebhook(payload, options) {
|
|
1288
|
+
const body = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
1289
|
+
const id = _nullishCoalesce(options.id, () => ( `msg_signed_${signedIds += 1}`));
|
|
1290
|
+
const timestamp = _nullishCoalesce(options.timestamp, () => ( Math.floor(Date.now() / 1e3)));
|
|
1291
|
+
return { body, headers: await _chunk4JSJOX35cjs.signedHeaders.call(void 0, options.secret, id, timestamp, body) };
|
|
1292
|
+
}
|
|
1293
|
+
function detailOf(type, now, data) {
|
|
1294
|
+
const start = now.toISOString();
|
|
1295
|
+
const end = new Date(now.getTime() + PERIOD_MS).toISOString();
|
|
1296
|
+
const paid = { amount: "9990000", mint: USDC, period_start: start, period_end: end };
|
|
1297
|
+
switch (type) {
|
|
1298
|
+
case "subscription.renewal_upcoming":
|
|
1299
|
+
return {
|
|
1300
|
+
can_pay: true,
|
|
1301
|
+
renewal_issue: null,
|
|
1302
|
+
amount: paid.amount,
|
|
1303
|
+
mint: USDC,
|
|
1304
|
+
// The check's window opens three days before the charge at most.
|
|
1305
|
+
due_at: new Date(now.getTime() + 3 * 24 * 3600 * 1e3).toISOString()
|
|
1306
|
+
};
|
|
1307
|
+
case "subscription.renewed":
|
|
1308
|
+
return { ...paid, signature: "fake_signature" };
|
|
1309
|
+
case "subscription.payment_failed":
|
|
1310
|
+
return {
|
|
1311
|
+
...paid,
|
|
1312
|
+
reason: "insufficient_balance",
|
|
1313
|
+
next_retry_at: new Date(now.getTime() + 24 * 3600 * 1e3).toISOString(),
|
|
1314
|
+
retry_deadline: null,
|
|
1315
|
+
retries_left: 1,
|
|
1316
|
+
retry_mode: "scheduled"
|
|
1317
|
+
};
|
|
1318
|
+
case "subscription.stopped":
|
|
1319
|
+
return { reason: "insufficient_balance" };
|
|
1320
|
+
case "subscription.access_granted":
|
|
1321
|
+
case "subscription.access_revoked":
|
|
1322
|
+
return accessDetailOf(data, start);
|
|
1323
|
+
case "subscription.created":
|
|
1324
|
+
return {
|
|
1325
|
+
first_payment: { amount: paid.amount, mint: USDC, signature: "fake_signature" }
|
|
1326
|
+
};
|
|
1327
|
+
case "subscription.ended":
|
|
1328
|
+
return data.end_reason === "cancelled" ? { changed_at: start, end_reason: "cancelled" } : {};
|
|
1329
|
+
default:
|
|
1330
|
+
return {};
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
function accessDetailOf(data, changedAt) {
|
|
1334
|
+
const { access, blocked_reason, access_until, status, end_reason } = data;
|
|
1335
|
+
const dated = blocked_reason !== null || status === "ended" && (end_reason === "cancelled" || end_reason === "plan_ended");
|
|
1336
|
+
return {
|
|
1337
|
+
changed_at: changedAt,
|
|
1338
|
+
blocked_reason,
|
|
1339
|
+
access_until: access ? blocked_reason === null ? null : access_until : _nullishCoalesce(access_until, () => ( (dated ? changedAt : null)))
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
function subscriptionOf(type) {
|
|
1343
|
+
if (type === "subscription.paused") return { paused: true, next_charge_at: null };
|
|
1344
|
+
if (type !== "subscription.access_revoked") return {};
|
|
1345
|
+
return {
|
|
1346
|
+
status: "ended",
|
|
1347
|
+
end_reason: "cancelled",
|
|
1348
|
+
access: false,
|
|
1349
|
+
payment_status: "none",
|
|
1350
|
+
next_charge_at: null,
|
|
1351
|
+
access_until: null
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
function nothing(asked, plan) {
|
|
1355
|
+
return { wallet: asked.kind === "wallet" ? asked.value : null, plan, ...NOTHING };
|
|
1356
|
+
}
|
|
1357
|
+
var BASE58_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
1358
|
+
var EMAIL = /^[^\s@]+@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/;
|
|
1359
|
+
function invalidContact({ wallet, email }) {
|
|
1360
|
+
const reasons = [];
|
|
1361
|
+
if (typeof wallet === "string" && !BASE58_ADDRESS.test(wallet)) {
|
|
1362
|
+
reasons.push("wallet must be a base58 Solana address");
|
|
1363
|
+
}
|
|
1364
|
+
if (typeof email === "string") {
|
|
1365
|
+
const normalised = email.trim().toLowerCase();
|
|
1366
|
+
if (!EMAIL.test(normalised)) reasons.push("email must be an email address");
|
|
1367
|
+
if (normalised.length > 254) {
|
|
1368
|
+
reasons.push("email must be shorter than or equal to 254 characters");
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
return reasons.length ? error(400, "invalid_request", reasons) : null;
|
|
1372
|
+
}
|
|
1373
|
+
var LIMIT_MESSAGE = "limit must be a whole number from 1 to 100";
|
|
1374
|
+
function limitOf(value) {
|
|
1375
|
+
const limit = /^\d{1,4}$/.test(value) ? Number(value) : NaN;
|
|
1376
|
+
return limit >= 1 && limit <= 100 ? limit : null;
|
|
1377
|
+
}
|
|
1378
|
+
function invalidSubmit(body) {
|
|
1379
|
+
const { transaction, terms_signature } = _nullishCoalesce(body, () => ( {}));
|
|
1380
|
+
const reasons = [];
|
|
1381
|
+
if (typeof transaction !== "string" || transaction.length > 2048) {
|
|
1382
|
+
reasons.push("transaction must be shorter than or equal to 2048 characters");
|
|
1383
|
+
}
|
|
1384
|
+
if (typeof transaction !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(transaction)) {
|
|
1385
|
+
reasons.push("transaction must be base64");
|
|
1386
|
+
}
|
|
1387
|
+
if (typeof terms_signature !== "string" || terms_signature === "") {
|
|
1388
|
+
reasons.push("terms_signature must be a base58 signature");
|
|
1389
|
+
}
|
|
1390
|
+
return reasons.length ? error(400, "invalid_request", reasons) : null;
|
|
1391
|
+
}
|
|
1392
|
+
function askedIn(query) {
|
|
1393
|
+
try {
|
|
1394
|
+
return _chunk4JSJOX35cjs.customerOf.call(void 0, {
|
|
1395
|
+
wallet: query["wallet"],
|
|
1396
|
+
external_id: query["external_id"],
|
|
1397
|
+
email: query["email"]
|
|
1398
|
+
});
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
function withAttempts(answer, attempts) {
|
|
1404
|
+
if (attempts) return { ...answer, attempts: _nullishCoalesce(answer.attempts, () => ( [])) };
|
|
1405
|
+
const light = { ...answer };
|
|
1406
|
+
delete light.attempts;
|
|
1407
|
+
return light;
|
|
1408
|
+
}
|
|
1409
|
+
function fakePlan(slug, { token, ...over } = {}) {
|
|
1410
|
+
const symbol = over.symbol === void 0 ? token ? null : "USDC" : over.symbol;
|
|
1411
|
+
const vouched = token === void 0 && symbol !== null;
|
|
1412
|
+
return {
|
|
1413
|
+
slug,
|
|
1414
|
+
name: slug,
|
|
1415
|
+
description: null,
|
|
1416
|
+
project_name: "Test project",
|
|
1417
|
+
logo_url: null,
|
|
1418
|
+
amount: "9990000",
|
|
1419
|
+
amount_display: "9.99",
|
|
1420
|
+
amount_usd: vouched ? _nullishCoalesce(over.amount_display, () => ( "9.99")) : null,
|
|
1421
|
+
decimals: 6,
|
|
1422
|
+
symbol,
|
|
1423
|
+
token: {
|
|
1424
|
+
symbol: token ? null : symbol,
|
|
1425
|
+
name: vouched && symbol === "USDC" ? "USD Coin" : null,
|
|
1426
|
+
logo_url: null,
|
|
1427
|
+
verified: vouched,
|
|
1428
|
+
...token
|
|
1429
|
+
},
|
|
1430
|
+
mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
1431
|
+
period_hours: 720,
|
|
1432
|
+
network: "mainnet-beta",
|
|
1433
|
+
status: "active",
|
|
1434
|
+
available: true,
|
|
1435
|
+
ends_at: null,
|
|
1436
|
+
...over
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
var RETRYABLE_CODES = /* @__PURE__ */ new Set([
|
|
1440
|
+
"rate_limited",
|
|
1441
|
+
"internal_error",
|
|
1442
|
+
"unavailable",
|
|
1443
|
+
"comeback_in_flight",
|
|
1444
|
+
"comeback_period_rolling",
|
|
1445
|
+
"pending_cap_reached",
|
|
1446
|
+
"subscription_changed",
|
|
1447
|
+
"retry_too_soon",
|
|
1448
|
+
"pull_running",
|
|
1449
|
+
"network_unavailable"
|
|
1450
|
+
]);
|
|
1451
|
+
function waitOf(method, path, status, code, tier) {
|
|
1452
|
+
if (code === "retry_too_soon")
|
|
1453
|
+
return (tier === "free" ? FREE_RETRY_WAIT_MS : PAID_RETRY_WAIT_MS) / 1e3;
|
|
1454
|
+
if (status !== 503 || method !== "POST" || !path.startsWith("/v1/subscriptions")) {
|
|
1455
|
+
return void 0;
|
|
1456
|
+
}
|
|
1457
|
+
if (code === "unavailable" && path.endsWith("/submit")) return 2;
|
|
1458
|
+
if (code === "unavailable" && path.endsWith("/retry")) return 20;
|
|
1459
|
+
return 10;
|
|
1460
|
+
}
|
|
1461
|
+
function error(status, code, message, { retryable, retryAfter } = {}) {
|
|
1462
|
+
return Response.json(
|
|
1463
|
+
{
|
|
1464
|
+
statusCode: status,
|
|
1465
|
+
error: _nullishCoalesce(REASONS[status], () => ( "Error")),
|
|
1466
|
+
message,
|
|
1467
|
+
code,
|
|
1468
|
+
retryable: _nullishCoalesce(retryable, () => ( (RETRYABLE_CODES.has(code) || status === 408 || status === 429 || status >= 500)))
|
|
1469
|
+
},
|
|
1470
|
+
{
|
|
1471
|
+
status,
|
|
1472
|
+
...retryAfter !== void 0 && { headers: { "Retry-After": String(retryAfter) } }
|
|
1473
|
+
}
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
async function stamped(response, id) {
|
|
1477
|
+
const headers = new Headers(response.headers);
|
|
1478
|
+
headers.set(REQUEST_ID_HEADER, id);
|
|
1479
|
+
headers.set(_chunk4JSJOX35cjs.API_VERSION_HEADER, _chunk4JSJOX35cjs.API_VERSION);
|
|
1480
|
+
if (response.ok) return new Response(response.body, { status: response.status, headers });
|
|
1481
|
+
const body = await response.json();
|
|
1482
|
+
return Response.json({ ...body, request_id: id }, { status: response.status, headers });
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
exports.FakeMesub = FakeMesub; exports.signWebhook = signWebhook;
|
|
1488
|
+
//# sourceMappingURL=testing.cjs.map
|