@tollwyn/baukasten 0.0.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +254 -1
  2. package/dist/bin/check.d.ts +2 -0
  3. package/dist/bin/check.js +38 -0
  4. package/dist/bin/migrate.d.ts +2 -0
  5. package/dist/bin/migrate.js +111 -0
  6. package/dist/bin/stubs.d.ts +2 -0
  7. package/dist/bin/stubs.js +38 -0
  8. package/dist/bin/test-accounts.d.ts +2 -0
  9. package/dist/bin/test-accounts.js +62 -0
  10. package/dist/check.d.ts +20 -0
  11. package/dist/check.js +285 -0
  12. package/dist/index.d.ts +7 -0
  13. package/dist/index.js +9 -0
  14. package/dist/product.d.ts +161 -0
  15. package/dist/product.js +152 -0
  16. package/dist/server/accounts.d.ts +128 -0
  17. package/dist/server/accounts.js +282 -0
  18. package/dist/server/adapter/db.d.ts +5 -0
  19. package/dist/server/adapter/db.js +63 -0
  20. package/dist/server/adapter/log.d.ts +3 -0
  21. package/dist/server/adapter/log.js +12 -0
  22. package/dist/server/adapter/mailer.d.ts +4 -0
  23. package/dist/server/adapter/mailer.js +56 -0
  24. package/dist/server/adapter/migrate.d.ts +21 -0
  25. package/dist/server/adapter/migrate.js +113 -0
  26. package/dist/server/adapter/secrets.d.ts +2 -0
  27. package/dist/server/adapter/secrets.js +40 -0
  28. package/dist/server/adapter/stripe.d.ts +11 -0
  29. package/dist/server/adapter/stripe.js +333 -0
  30. package/dist/server/api.d.ts +25 -0
  31. package/dist/server/api.js +98 -0
  32. package/dist/server/baukasten-migrations.d.ts +4 -0
  33. package/dist/server/baukasten-migrations.js +126 -0
  34. package/dist/server/billing.d.ts +94 -0
  35. package/dist/server/billing.js +349 -0
  36. package/dist/server/catalog.d.ts +39 -0
  37. package/dist/server/catalog.js +193 -0
  38. package/dist/server/config.d.ts +108 -0
  39. package/dist/server/config.js +244 -0
  40. package/dist/server/endpoints.d.ts +44 -0
  41. package/dist/server/endpoints.js +159 -0
  42. package/dist/server/fields.d.ts +7 -0
  43. package/dist/server/fields.js +18 -0
  44. package/dist/server/headers.d.ts +1 -0
  45. package/dist/server/headers.js +9 -0
  46. package/dist/server/health.d.ts +34 -0
  47. package/dist/server/health.js +116 -0
  48. package/dist/server/hook.d.ts +92 -0
  49. package/dist/server/hook.js +293 -0
  50. package/dist/server/index.d.ts +18 -0
  51. package/dist/server/index.js +11 -0
  52. package/dist/server/legal/de.d.ts +2 -0
  53. package/dist/server/legal/de.js +251 -0
  54. package/dist/server/legal/document.d.ts +37 -0
  55. package/dist/server/legal/document.js +30 -0
  56. package/dist/server/legal/en.d.ts +2 -0
  57. package/dist/server/legal/en.js +251 -0
  58. package/dist/server/legal/fleet.d.ts +114 -0
  59. package/dist/server/legal/fleet.js +89 -0
  60. package/dist/server/legal/index.d.ts +3 -0
  61. package/dist/server/legal/index.js +11 -0
  62. package/dist/server/limits.d.ts +58 -0
  63. package/dist/server/limits.js +76 -0
  64. package/dist/server/log.d.ts +26 -0
  65. package/dist/server/log.js +61 -0
  66. package/dist/server/machine.d.ts +4 -0
  67. package/dist/server/machine.js +57 -0
  68. package/dist/server/mail.d.ts +20 -0
  69. package/dist/server/mail.js +29 -0
  70. package/dist/server/messages.d.ts +105 -0
  71. package/dist/server/messages.js +232 -0
  72. package/dist/server/migrations.d.ts +28 -0
  73. package/dist/server/migrations.js +59 -0
  74. package/dist/server/openapi.d.ts +24 -0
  75. package/dist/server/openapi.js +173 -0
  76. package/dist/server/pages.d.ts +158 -0
  77. package/dist/server/pages.js +491 -0
  78. package/dist/server/ports.d.ts +273 -0
  79. package/dist/server/ports.js +1 -0
  80. package/dist/server/runs.d.ts +30 -0
  81. package/dist/server/runs.js +55 -0
  82. package/dist/server/sales.d.ts +38 -0
  83. package/dist/server/sales.js +132 -0
  84. package/dist/server/services.d.ts +57 -0
  85. package/dist/server/services.js +44 -0
  86. package/dist/server/start.d.ts +14 -0
  87. package/dist/server/start.js +65 -0
  88. package/dist/server/stripe.d.ts +33 -0
  89. package/dist/server/stripe.js +29 -0
  90. package/dist/server/terms.d.ts +7 -0
  91. package/dist/server/terms.js +9 -0
  92. package/dist/server/test-accounts.d.ts +28 -0
  93. package/dist/server/test-accounts.js +62 -0
  94. package/dist/server/via.d.ts +9 -0
  95. package/dist/server/via.js +15 -0
  96. package/dist/server/web.d.ts +48 -0
  97. package/dist/server/web.js +105 -0
  98. package/dist/shopify/adapter/shopify.d.ts +8 -0
  99. package/dist/shopify/adapter/shopify.js +232 -0
  100. package/dist/shopify/admin.d.ts +33 -0
  101. package/dist/shopify/admin.js +47 -0
  102. package/dist/shopify/app.d.ts +60 -0
  103. package/dist/shopify/app.js +67 -0
  104. package/dist/shopify/config.d.ts +53 -0
  105. package/dist/shopify/config.js +133 -0
  106. package/dist/shopify/constants.d.ts +27 -0
  107. package/dist/shopify/constants.js +27 -0
  108. package/dist/shopify/headers.d.ts +1 -0
  109. package/dist/shopify/headers.js +8 -0
  110. package/dist/shopify/hmac.d.ts +1 -0
  111. package/dist/shopify/hmac.js +9 -0
  112. package/dist/shopify/hook.d.ts +60 -0
  113. package/dist/shopify/hook.js +508 -0
  114. package/dist/shopify/id-token.d.ts +12 -0
  115. package/dist/shopify/id-token.js +66 -0
  116. package/dist/shopify/index.d.ts +19 -0
  117. package/dist/shopify/index.js +15 -0
  118. package/dist/shopify/legal/de.d.ts +3 -0
  119. package/dist/shopify/legal/de.js +123 -0
  120. package/dist/shopify/legal/en.d.ts +3 -0
  121. package/dist/shopify/legal/en.js +121 -0
  122. package/dist/shopify/legal/index.d.ts +19 -0
  123. package/dist/shopify/legal/index.js +8 -0
  124. package/dist/shopify/legal/shopify.d.ts +16 -0
  125. package/dist/shopify/legal/shopify.js +21 -0
  126. package/dist/shopify/migrations.d.ts +4 -0
  127. package/dist/shopify/migrations.js +67 -0
  128. package/dist/shopify/pages.d.ts +45 -0
  129. package/dist/shopify/pages.js +146 -0
  130. package/dist/shopify/ports.d.ts +77 -0
  131. package/dist/shopify/ports.js +1 -0
  132. package/dist/shopify/sales.d.ts +4 -0
  133. package/dist/shopify/sales.js +27 -0
  134. package/dist/shopify/scopes.d.ts +2 -0
  135. package/dist/shopify/scopes.js +13 -0
  136. package/dist/shopify/seal.d.ts +12 -0
  137. package/dist/shopify/seal.js +60 -0
  138. package/dist/shopify/shop.d.ts +4 -0
  139. package/dist/shopify/shop.js +15 -0
  140. package/dist/shopify/shops.d.ts +76 -0
  141. package/dist/shopify/shops.js +256 -0
  142. package/dist/shopify/sveltekit.d.ts +19 -0
  143. package/dist/shopify/sveltekit.js +18 -0
  144. package/dist/shopify/texts.d.ts +28 -0
  145. package/dist/shopify/texts.js +49 -0
  146. package/dist/shopify/toml.d.ts +22 -0
  147. package/dist/shopify/toml.js +125 -0
  148. package/dist/shopify/webhooks.d.ts +65 -0
  149. package/dist/shopify/webhooks.js +220 -0
  150. package/dist/stubs.d.ts +15 -0
  151. package/dist/stubs.js +287 -0
  152. package/dist/sveltekit.d.ts +20 -0
  153. package/dist/sveltekit.js +18 -0
  154. package/dist/testing/app.d.ts +36 -0
  155. package/dist/testing/app.js +155 -0
  156. package/dist/testing/browser.d.ts +21 -0
  157. package/dist/testing/browser.js +21 -0
  158. package/dist/testing/connection.d.ts +7 -0
  159. package/dist/testing/connection.js +303 -0
  160. package/dist/testing/database.d.ts +21 -0
  161. package/dist/testing/database.js +54 -0
  162. package/dist/testing/env-example.d.ts +4 -0
  163. package/dist/testing/env-example.js +33 -0
  164. package/dist/testing/index.d.ts +9 -0
  165. package/dist/testing/index.js +9 -0
  166. package/dist/testing/shopify-connection.d.ts +8 -0
  167. package/dist/testing/shopify-connection.js +313 -0
  168. package/dist/testing/shopify.d.ts +71 -0
  169. package/dist/testing/shopify.js +278 -0
  170. package/dist/testing/smtp.d.ts +38 -0
  171. package/dist/testing/smtp.js +165 -0
  172. package/dist/testing/stripe.d.ts +128 -0
  173. package/dist/testing/stripe.js +511 -0
  174. package/dist/texts.d.ts +139 -0
  175. package/dist/texts.js +265 -0
  176. package/dist/ui/AccountPage.svelte +143 -0
  177. package/dist/ui/AccountPage.svelte.d.ts +10 -0
  178. package/dist/ui/ConfirmEmailPage.svelte +21 -0
  179. package/dist/ui/ConfirmEmailPage.svelte.d.ts +10 -0
  180. package/dist/ui/ErrorPage.svelte +14 -0
  181. package/dist/ui/ErrorPage.svelte.d.ts +3 -0
  182. package/dist/ui/Field.svelte +12 -0
  183. package/dist/ui/Field.svelte.d.ts +10 -0
  184. package/dist/ui/ForgotPasswordPage.svelte +32 -0
  185. package/dist/ui/ForgotPasswordPage.svelte.d.ts +7 -0
  186. package/dist/ui/Form.svelte +30 -0
  187. package/dist/ui/Form.svelte.d.ts +11 -0
  188. package/dist/ui/Frame.svelte +84 -0
  189. package/dist/ui/Frame.svelte.d.ts +12 -0
  190. package/dist/ui/LegalPage.svelte +38 -0
  191. package/dist/ui/LegalPage.svelte.d.ts +9 -0
  192. package/dist/ui/Linked.svelte +5 -0
  193. package/dist/ui/Linked.svelte.d.ts +8 -0
  194. package/dist/ui/LoginPage.svelte +41 -0
  195. package/dist/ui/LoginPage.svelte.d.ts +10 -0
  196. package/dist/ui/LogoutPage.svelte +19 -0
  197. package/dist/ui/LogoutPage.svelte.d.ts +9 -0
  198. package/dist/ui/ResetPasswordPage.svelte +30 -0
  199. package/dist/ui/ResetPasswordPage.svelte.d.ts +10 -0
  200. package/dist/ui/SignupPage.svelte +52 -0
  201. package/dist/ui/SignupPage.svelte.d.ts +10 -0
  202. package/dist/ui/VerifyEmailPage.svelte +50 -0
  203. package/dist/ui/VerifyEmailPage.svelte.d.ts +10 -0
  204. package/dist/ui/base.css +287 -0
  205. package/dist/ui/data.d.ts +167 -0
  206. package/dist/ui/data.js +1 -0
  207. package/dist/ui/frame.d.ts +17 -0
  208. package/dist/ui/frame.js +27 -0
  209. package/dist/ui/index.d.ts +15 -0
  210. package/dist/ui/index.js +15 -0
  211. package/dist/variables.d.ts +20 -0
  212. package/dist/variables.js +62 -0
  213. package/package.json +88 -4
@@ -0,0 +1,511 @@
1
+ // A double of Stripe on a free local port, for the tests of a product. It takes any key and moves no
2
+ // money; a customer belongs to the mode of the key that made it, and a key of the other mode does not
3
+ // reach it or its subscriptions, as at Stripe. The catalog it shares between the modes. It starts as
4
+ // an account whose customer portal was saved once in the Dashboard, copied from another product: its
5
+ // head is that product's. It needs nothing but Node, so no image of a product carries a dependency
6
+ // for it.
7
+ import { createHmac } from 'node:crypto';
8
+ import { createServer } from 'node:http';
9
+ /** Stripe's form encoding (`a[b][0][c]=1`) read into nested objects, values as sent. */
10
+ function parseForm(body) {
11
+ const result = {};
12
+ for (const [key, value] of new URLSearchParams(body)) {
13
+ const path = key.replace(/\]/g, '').split('[');
14
+ let node = result;
15
+ path.forEach((part, index) => {
16
+ if (index === path.length - 1) {
17
+ node[part] = value;
18
+ return;
19
+ }
20
+ const next = node[part];
21
+ if (typeof next === 'object')
22
+ node = next;
23
+ else {
24
+ const fresh = {};
25
+ node[part] = fresh;
26
+ node = fresh;
27
+ }
28
+ });
29
+ }
30
+ return result;
31
+ }
32
+ /** A form as Stripe keeps it: lists as arrays, flags and whole numbers typed. */
33
+ function typed(value) {
34
+ if (typeof value === 'string') {
35
+ if (value === 'true' || value === 'false')
36
+ return value === 'true';
37
+ return /^\d+$/.test(value) ? Number(value) : value;
38
+ }
39
+ const entries = Object.entries(value);
40
+ if (entries.length > 0 && entries.every(([key]) => /^\d+$/.test(key))) {
41
+ return entries.sort(([a], [b]) => Number(a) - Number(b)).map(([, item]) => typed(item));
42
+ }
43
+ return Object.fromEntries(entries.map(([key, item]) => [key, typed(item)]));
44
+ }
45
+ function objectOf(value) {
46
+ const json = typed(value);
47
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
48
+ throw new Error('the double expected an object in the form');
49
+ }
50
+ return json;
51
+ }
52
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
53
+ /** The value at `path`, if it is there; a list takes the number of its item. */
54
+ function at(value, ...path) {
55
+ let node = value;
56
+ for (const key of path) {
57
+ node = Array.isArray(node) ? node[Number(key)] : isObject(node) ? node[key] : undefined;
58
+ }
59
+ return node;
60
+ }
61
+ function text(value, ...path) {
62
+ const found = at(value, ...path);
63
+ return typeof found === 'string' ? found : null;
64
+ }
65
+ function whole(value, ...path) {
66
+ const found = at(value, ...path);
67
+ return typeof found === 'number' ? found : null;
68
+ }
69
+ /** The metadata of a request, each value as text, as Stripe keeps it. */
70
+ function metadataOf(form) {
71
+ const metadata = form.metadata;
72
+ if (!isObject(metadata))
73
+ return {};
74
+ return Object.fromEntries(Object.entries(metadata).map(([key, value]) => [
75
+ key,
76
+ typeof value === 'string' ? value : JSON.stringify(value)
77
+ ]));
78
+ }
79
+ /** An update: objects merge key by key, everything else is replaced. */
80
+ function merge(target, changes) {
81
+ for (const [key, value] of Object.entries(changes)) {
82
+ const current = target[key];
83
+ if (isObject(value) && isObject(current))
84
+ merge(current, value);
85
+ else
86
+ target[key] = value;
87
+ }
88
+ }
89
+ function readBody(request) {
90
+ return new Promise((resolve, reject) => {
91
+ let body = '';
92
+ request.on('data', (chunk) => (body += chunk.toString()));
93
+ request.on('end', () => resolve(body));
94
+ request.on('error', reject);
95
+ });
96
+ }
97
+ const page = (title, body) => `<!doctype html><html><head><title>${title}</title></head><body><h1>${title}</h1>${body}</body></html>`;
98
+ /** The products of a portal configuration come only when they are asked for. */
99
+ function shown(configuration, expanded) {
100
+ if (expanded)
101
+ return configuration;
102
+ const copy = structuredClone(configuration);
103
+ const update = at(copy, 'features', 'subscription_update');
104
+ if (isObject(update))
105
+ delete update.products;
106
+ return copy;
107
+ }
108
+ /** What the Stripe-Signature header carries for this payload at this time. */
109
+ export function stripeSignature(payload, secret, seconds) {
110
+ const v1 = createHmac('sha256', secret).update(`${seconds}.${payload}`).digest('hex');
111
+ return `t=${seconds},v1=${v1}`;
112
+ }
113
+ export async function startStripe(options = {}) {
114
+ const webhookSecret = 'whsec_double';
115
+ let counter = 0;
116
+ const next = (prefix) => `${prefix}_double${++counter}`;
117
+ let base = '';
118
+ let connected = null;
119
+ /** Events on their way, and the answers that were no success. */
120
+ const pending = new Set();
121
+ const refused = [];
122
+ /** Sends an event after the request that caused it is answered, as Stripe does. */
123
+ function later(type, object) {
124
+ const delivery = double
125
+ .send(type, object)
126
+ .then(async (response) => {
127
+ if (!response.ok)
128
+ refused.push(`${type}: ${response.status} ${await response.text()}`);
129
+ })
130
+ .catch((error) => {
131
+ refused.push(`${type}: ${String(error)}`);
132
+ })
133
+ .finally(() => pending.delete(delivery));
134
+ pending.add(delivery);
135
+ }
136
+ const double = {
137
+ url: '',
138
+ webhookSecret,
139
+ target: null,
140
+ products: new Map(),
141
+ prices: new Map(),
142
+ portals: new Map(),
143
+ endpoints: new Map(),
144
+ customers: new Map(),
145
+ subscriptions: new Map(),
146
+ sessions: new Map(),
147
+ writes: [],
148
+ retrieved: 0,
149
+ addEndpoint(url, status = 'enabled') {
150
+ const id = next('we');
151
+ double.endpoints.set(id, { id, object: 'webhook_endpoint', url, status });
152
+ return id;
153
+ },
154
+ connect(url, target) {
155
+ if (connected !== null)
156
+ connected.status = 'disabled';
157
+ const id = double.addEndpoint(url);
158
+ connected = double.endpoints.get(id) ?? null;
159
+ double.target = target;
160
+ },
161
+ async send(type, object, sendOptions = {}) {
162
+ if (double.target === null)
163
+ throw new Error('the Stripe double has no target for events');
164
+ const payload = JSON.stringify({
165
+ id: sendOptions.id ?? next('evt'),
166
+ object: 'event',
167
+ api_version: '2026-08-26.dahlia',
168
+ created: Math.floor(Date.now() / 1_000),
169
+ livemode: false,
170
+ type,
171
+ data: { object }
172
+ });
173
+ const signature = stripeSignature(payload, sendOptions.secret ?? webhookSecret, Math.floor(Date.now() / 1_000));
174
+ return fetch(double.target, {
175
+ method: 'POST',
176
+ headers: { 'content-type': 'application/json', 'stripe-signature': signature },
177
+ body: payload
178
+ });
179
+ },
180
+ async pay(sessionId, payOptions = {}) {
181
+ const session = double.sessions.get(sessionId);
182
+ if (session === undefined)
183
+ throw new Error(`the double has no session ${sessionId}`);
184
+ const price = double.prices.get(session.item.price);
185
+ if (price === undefined)
186
+ throw new Error(`the double has no price ${session.item.price}`);
187
+ const quantity = payOptions.quantity ?? session.item.quantity;
188
+ if (session.mode === 'subscription') {
189
+ const invoice = next('in');
190
+ const subscription = {
191
+ id: next('sub'),
192
+ object: 'subscription',
193
+ customer: session.customer,
194
+ status: 'active',
195
+ latest_invoice: invoice,
196
+ items: {
197
+ object: 'list',
198
+ data: [{ id: next('si'), object: 'subscription_item', quantity, price }]
199
+ }
200
+ };
201
+ double.subscriptions.set(subscription.id, subscription);
202
+ session.subscription = subscription.id;
203
+ session.payment_status = 'paid';
204
+ await expectOk(double.send('checkout.session.completed', session));
205
+ await expectOk(double.send('customer.subscription.created', subscription));
206
+ await expectOk(double.send('invoice.paid', {
207
+ id: invoice,
208
+ object: 'invoice',
209
+ customer: session.customer,
210
+ amount_paid: price.unit_amount * quantity,
211
+ currency: price.currency,
212
+ status_transitions: { paid_at: Math.floor(Date.now() / 1_000) }
213
+ }));
214
+ return;
215
+ }
216
+ session.amount_total = price.unit_amount;
217
+ session.currency = price.currency;
218
+ session.payment_status = payOptions.later === true ? 'unpaid' : 'paid';
219
+ await expectOk(double.send('checkout.session.completed', session));
220
+ if (payOptions.later === true) {
221
+ session.payment_status = 'paid';
222
+ await expectOk(double.send('checkout.session.async_payment_succeeded', session));
223
+ }
224
+ },
225
+ async idle() {
226
+ while (pending.size > 0)
227
+ await Promise.all([...pending]);
228
+ if (refused.length > 0) {
229
+ throw new Error(`the webhook of the app refused: ${refused.splice(0).join('; ')}`);
230
+ }
231
+ },
232
+ async stop() {
233
+ while (pending.size > 0)
234
+ await Promise.all([...pending]);
235
+ await new Promise((resolve) => server.close(() => resolve()));
236
+ }
237
+ };
238
+ async function expectOk(sent) {
239
+ const response = await sent;
240
+ if (!response.ok) {
241
+ throw new Error(`the webhook of the app answered ${response.status}: ${await response.text()}`);
242
+ }
243
+ }
244
+ // The default configuration, as an account copied from another product brings it along.
245
+ if (options.portal ?? true) {
246
+ double.portals.set('bpc_double', {
247
+ id: 'bpc_double',
248
+ object: 'billing_portal.configuration',
249
+ active: true,
250
+ is_default: true,
251
+ business_profile: {
252
+ headline: 'Another product',
253
+ privacy_policy_url: 'https://another.example/privacy',
254
+ terms_of_service_url: 'https://another.example/terms'
255
+ },
256
+ default_return_url: null,
257
+ features: {
258
+ customer_update: { enabled: false, allowed_updates: [] },
259
+ invoice_history: { enabled: true },
260
+ payment_method_update: { enabled: false },
261
+ subscription_cancel: { enabled: false, mode: 'at_period_end', proration_behavior: 'none' },
262
+ subscription_update: {
263
+ enabled: false,
264
+ default_allowed_updates: [],
265
+ proration_behavior: 'none',
266
+ products: []
267
+ }
268
+ }
269
+ });
270
+ }
271
+ const server = createServer((request, response) => {
272
+ void (async () => {
273
+ const url = new URL(request.url ?? '/', base);
274
+ const body = await readBody(request);
275
+ const send = (status, value) => response
276
+ .writeHead(status, { 'content-type': 'application/json' })
277
+ .end(JSON.stringify(value));
278
+ const html = (value) => response.writeHead(200, { 'content-type': 'text/html' }).end(value);
279
+ const failed = (status, message, code) => send(status, {
280
+ error: { type: 'invalid_request_error', message, ...(code === undefined ? {} : { code }) }
281
+ });
282
+ const missing = (what) => failed(404, `No such ${what}`, 'resource_missing');
283
+ const path = url.pathname;
284
+ const form = objectOf(parseForm(body));
285
+ const query = objectOf(parseForm(url.search.slice(1)));
286
+ const method = request.method ?? 'GET';
287
+ const livemode = /^Bearer (?:sk|rk)_live_/.test(request.headers.authorization ?? '');
288
+ /** A customer of the mode of the key; one of the other mode is missing for it. */
289
+ const customerOf = (id) => {
290
+ const customer = double.customers.get(id ?? '');
291
+ return customer?.livemode === livemode ? customer : undefined;
292
+ };
293
+ if (method !== 'GET' && path.startsWith('/v1/'))
294
+ double.writes.push(`${method} ${path}`);
295
+ const list = (data) => send(200, { object: 'list', data, has_more: false, url: path });
296
+ let match;
297
+ if (path === '/v1/products' && method === 'POST') {
298
+ const id = text(form, 'id') ?? next('prod');
299
+ if (double.products.has(id)) {
300
+ return failed(400, 'Product already exists.', 'resource_already_exists');
301
+ }
302
+ const product = {
303
+ id,
304
+ object: 'product',
305
+ name: text(form, 'name') ?? '',
306
+ active: true
307
+ };
308
+ double.products.set(id, product);
309
+ return send(200, product);
310
+ }
311
+ if ((match = /^\/v1\/products\/([^/]+)$/.exec(path)) !== null) {
312
+ const product = double.products.get(match[1] ?? '');
313
+ if (product === undefined)
314
+ return missing('product');
315
+ if (method === 'POST') {
316
+ product.name = text(form, 'name') ?? product.name;
317
+ const active = at(form, 'active');
318
+ if (typeof active === 'boolean')
319
+ product.active = active;
320
+ }
321
+ return send(200, product);
322
+ }
323
+ if (path === '/v1/prices' && method === 'GET') {
324
+ const keys = at(query, 'lookup_keys');
325
+ const active = at(query, 'active');
326
+ return list([...double.prices.values()].filter((price) => (!Array.isArray(keys) || keys.includes(price.lookup_key ?? '')) &&
327
+ (typeof active !== 'boolean' || price.active === active)));
328
+ }
329
+ if (path === '/v1/prices' && method === 'POST') {
330
+ const key = text(form, 'lookup_key');
331
+ const holder = [...double.prices.values()].find((price) => price.lookup_key === key);
332
+ if (holder !== undefined && key !== null) {
333
+ if (at(form, 'transfer_lookup_key') !== true) {
334
+ return failed(400, `A price (\`${holder.id}\`) already uses that lookup key.`);
335
+ }
336
+ holder.lookup_key = null;
337
+ }
338
+ const interval = text(form, 'recurring', 'interval');
339
+ const price = {
340
+ id: next('price'),
341
+ object: 'price',
342
+ product: text(form, 'product') ?? '',
343
+ lookup_key: key,
344
+ unit_amount: whole(form, 'unit_amount') ?? 0,
345
+ currency: text(form, 'currency') ?? '',
346
+ active: true,
347
+ type: interval === null ? 'one_time' : 'recurring',
348
+ recurring: interval === null ? null : { interval, interval_count: 1 }
349
+ };
350
+ double.prices.set(price.id, price);
351
+ return send(200, price);
352
+ }
353
+ if (path === '/v1/billing_portal/configurations' && method === 'GET') {
354
+ const expand = at(query, 'expand');
355
+ const expanded = Array.isArray(expand) && expand.includes('data.features.subscription_update.products');
356
+ return list([...double.portals.values()]
357
+ .filter((portal) => {
358
+ const wanted = at(query, 'is_default');
359
+ return typeof wanted !== 'boolean' || portal.is_default === wanted;
360
+ })
361
+ .map((portal) => shown(portal, expanded)));
362
+ }
363
+ if ((match = /^\/v1\/billing_portal\/configurations\/([^/]+)$/.exec(path)) !== null) {
364
+ const portal = double.portals.get(match[1] ?? '');
365
+ if (portal === undefined)
366
+ return missing('configuration');
367
+ const { expand, ...changes } = form;
368
+ merge(portal, changes);
369
+ const expanded = Array.isArray(expand) && expand.includes('features.subscription_update.products');
370
+ return send(200, shown(portal, expanded));
371
+ }
372
+ if (path === '/v1/webhook_endpoints' && method === 'GET') {
373
+ return list([...double.endpoints.values()]);
374
+ }
375
+ if (path === '/v1/customers' && method === 'POST') {
376
+ const customer = {
377
+ id: next('cus'),
378
+ object: 'customer',
379
+ email: text(form, 'email') ?? '',
380
+ metadata: metadataOf(form),
381
+ livemode
382
+ };
383
+ double.customers.set(customer.id, customer);
384
+ return send(200, customer);
385
+ }
386
+ if ((match = /^\/v1\/customers\/([^/]+)$/.exec(path)) !== null && method === 'POST') {
387
+ const customer = customerOf(match[1] ?? null);
388
+ if (customer === undefined)
389
+ return missing('customer');
390
+ customer.email = text(form, 'email') ?? customer.email;
391
+ return send(200, customer);
392
+ }
393
+ if (path === '/v1/subscriptions' && method === 'GET') {
394
+ const customer = customerOf(text(query, 'customer'));
395
+ if (customer === undefined)
396
+ return missing('customer');
397
+ return list([...double.subscriptions.values()].filter((item) => item.customer === customer.id));
398
+ }
399
+ if ((match = /^\/v1\/subscriptions\/([^/]+)$/.exec(path)) !== null) {
400
+ const subscription = double.subscriptions.get(match[1] ?? '');
401
+ if (method === 'GET')
402
+ double.retrieved += 1;
403
+ if (subscription === undefined || customerOf(subscription.customer) === undefined) {
404
+ return missing('subscription');
405
+ }
406
+ if (method === 'DELETE') {
407
+ subscription.status = 'canceled';
408
+ send(200, subscription);
409
+ // Stripe sends the event on its own time, after its answer.
410
+ later('customer.subscription.deleted', subscription);
411
+ return;
412
+ }
413
+ return send(200, subscription);
414
+ }
415
+ if (path === '/v1/checkout/sessions' && method === 'POST') {
416
+ if (customerOf(text(form, 'customer')) === undefined)
417
+ return missing('customer');
418
+ const id = next('cs');
419
+ const max = whole(form, 'line_items', '0', 'adjustable_quantity', 'maximum');
420
+ const session = {
421
+ id,
422
+ object: 'checkout.session',
423
+ mode: text(form, 'mode') ?? '',
424
+ customer: text(form, 'customer') ?? '',
425
+ url: `${base}/pay/${id}`,
426
+ subscription: null,
427
+ payment_status: 'unpaid',
428
+ amount_total: null,
429
+ currency: null,
430
+ metadata: metadataOf(form),
431
+ success_url: text(form, 'success_url') ?? '',
432
+ cancel_url: text(form, 'cancel_url') ?? '',
433
+ item: {
434
+ price: text(form, 'line_items', '0', 'price') ?? '',
435
+ quantity: whole(form, 'line_items', '0', 'quantity') ?? 1,
436
+ max
437
+ },
438
+ request: form
439
+ };
440
+ double.sessions.set(id, session);
441
+ return send(200, session);
442
+ }
443
+ if (path === '/v1/billing_portal/sessions' && method === 'POST') {
444
+ if (customerOf(text(form, 'customer')) === undefined)
445
+ return missing('customer');
446
+ const configuration = double.portals.get(text(form, 'configuration') ?? '');
447
+ if (configuration === undefined) {
448
+ return failed(400, 'No configuration provided and your default configuration has not been created.');
449
+ }
450
+ const back = encodeURIComponent(text(form, 'return_url') ?? '');
451
+ return send(200, {
452
+ id: next('bps'),
453
+ object: 'billing_portal.session',
454
+ url: `${base}/portal/${text(form, 'customer') ?? ''}?return=${back}`
455
+ });
456
+ }
457
+ // The pages a person sees at Stripe.
458
+ if ((match = /^\/pay\/([^/]+)$/.exec(path)) !== null) {
459
+ const session = double.sessions.get(match[1] ?? '');
460
+ if (session === undefined)
461
+ return send(404, {});
462
+ if (method === 'POST') {
463
+ const quantity = new URLSearchParams(body).get('quantity');
464
+ await double.pay(session.id, quantity === null ? {} : { quantity: Number(quantity) });
465
+ response.writeHead(303, { location: session.success_url }).end();
466
+ return;
467
+ }
468
+ const field = session.item.max === null
469
+ ? ''
470
+ : `<label>Quantity <input name="quantity" type="number" min="1" max="${session.item.max}" value="${session.item.quantity}"></label>`;
471
+ return html(page('Stripe Checkout (test double)', `<form method="POST">${field}<button type="submit">Pay</button></form>`));
472
+ }
473
+ if ((match = /^\/portal\/([^/]+)$/.exec(path)) !== null) {
474
+ const customer = match[1] ?? '';
475
+ const back = url.searchParams.get('return') ?? '/';
476
+ const subscription = [...double.subscriptions.values()].find((candidate) => candidate.customer === customer && candidate.status === 'active');
477
+ const item = subscription?.items.data[0];
478
+ if (method === 'POST' && subscription !== undefined && item !== undefined) {
479
+ const action = new URLSearchParams(body);
480
+ if (action.has('cancel'))
481
+ subscription.status = 'canceled';
482
+ else
483
+ item.quantity = Number(action.get('quantity'));
484
+ await expectOk(double.send(action.has('cancel')
485
+ ? 'customer.subscription.deleted'
486
+ : 'customer.subscription.updated', subscription));
487
+ response.writeHead(303, { location: back }).end();
488
+ return;
489
+ }
490
+ return html(page('Stripe customer portal (test double)', (item === undefined
491
+ ? '<p>No subscription.</p>'
492
+ : `<form method="POST"><label>Quantity <input name="quantity" type="number" min="1" value="${item.quantity}"></label><button type="submit">Update</button></form>` +
493
+ '<form method="POST"><input type="hidden" name="cancel" value="1"><button type="submit">Cancel subscription</button></form>') +
494
+ `<p><a href="${back}">Back</a></p>`));
495
+ }
496
+ return failed(404, `unknown ${method} ${path}`);
497
+ })().catch((error) => {
498
+ if (response.headersSent)
499
+ response.destroy(error instanceof Error ? error : undefined);
500
+ else
501
+ response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error));
502
+ });
503
+ });
504
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
505
+ const address = server.address();
506
+ if (address === null || typeof address === 'string')
507
+ throw new Error('the Stripe double has no port');
508
+ base = `http://127.0.0.1:${address.port}`;
509
+ double.url = base;
510
+ return double;
511
+ }
@@ -0,0 +1,139 @@
1
+ import type { Language } from './product.js';
2
+ export interface Linked {
3
+ before: string;
4
+ link: string;
5
+ after: string;
6
+ }
7
+ export interface Texts {
8
+ frame: {
9
+ navigation: string;
10
+ accountMenu: string;
11
+ logIn: string;
12
+ signUp: string;
13
+ account: string;
14
+ logOut: string;
15
+ unverified: string;
16
+ unverifiedLink: string;
17
+ legal: string;
18
+ imprint: string;
19
+ privacy: string;
20
+ terms: string;
21
+ };
22
+ error: {
23
+ notFound: string;
24
+ failed: string;
25
+ back: string;
26
+ };
27
+ wait: string;
28
+ signup: {
29
+ email: string;
30
+ password: string;
31
+ terms: Linked;
32
+ business: string;
33
+ button: string;
34
+ haveAccount: Linked;
35
+ takenLogIn: string;
36
+ takenReset: string;
37
+ };
38
+ login: {
39
+ title: string;
40
+ email: string;
41
+ password: string;
42
+ button: string;
43
+ forgot: string;
44
+ noAccount: Linked;
45
+ verified: string;
46
+ emailChanged: string;
47
+ };
48
+ logout: {
49
+ title: string;
50
+ button: string;
51
+ done: string;
52
+ deleted: string;
53
+ again: string;
54
+ };
55
+ forgot: {
56
+ title: string;
57
+ lead: string;
58
+ email: string;
59
+ button: string;
60
+ sent: string;
61
+ again: string;
62
+ };
63
+ reset: {
64
+ title: string;
65
+ password: string;
66
+ button: string;
67
+ invalid: Linked;
68
+ };
69
+ verify: {
70
+ title: string;
71
+ button: string;
72
+ apiAccount: string;
73
+ password: string;
74
+ invalid: string;
75
+ newLink: Linked;
76
+ logIn: Linked;
77
+ pending: (email: string) => string;
78
+ unsent: string;
79
+ resend: string;
80
+ resent: (email: string) => string;
81
+ confirmed: string;
82
+ continue: string;
83
+ };
84
+ confirm: {
85
+ title: string;
86
+ becomes: (email: string) => string;
87
+ button: string;
88
+ invalid: string;
89
+ };
90
+ account: {
91
+ title: string;
92
+ loggedIn: (email: string) => string;
93
+ verified: string;
94
+ emailChanged: string;
95
+ email: {
96
+ title: string;
97
+ current: (email: string) => string;
98
+ new: string;
99
+ password: string;
100
+ button: string;
101
+ sent: (email: string) => string;
102
+ };
103
+ password: {
104
+ title: string;
105
+ current: string;
106
+ new: string;
107
+ change: string;
108
+ set: string;
109
+ done: string;
110
+ };
111
+ keys: {
112
+ title: string;
113
+ lead: (example: string) => string;
114
+ name: string;
115
+ button: string;
116
+ created: string;
117
+ confirmFirst: string;
118
+ none: string;
119
+ columns: {
120
+ name: string;
121
+ key: string;
122
+ created: string;
123
+ };
124
+ revoke: string;
125
+ };
126
+ billing: {
127
+ title: string;
128
+ lead: string;
129
+ button: string;
130
+ };
131
+ delete: {
132
+ title: string;
133
+ lead: string;
134
+ confirm: string;
135
+ button: string;
136
+ };
137
+ };
138
+ }
139
+ export declare const TEXTS: Record<Language, Texts>;