@tollwyn/baukasten 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ import { createDb } from './adapter/db.js';
2
+ import { exitAfterLog, log } from './adapter/log.js';
3
+ import { createMailer } from './adapter/mailer.js';
4
+ import { migrationSources, migrationStatus } from './adapter/migrate.js';
5
+ import { ConfigError } from './config.js';
6
+ import { CHECK_TIMEOUT_MS, databaseCheck, deepHealth, mailCheck } from './health.js';
7
+ import { createLimits } from './limits.js';
8
+ const HEALTH = '/health';
9
+ const DEEP = '/health/deep';
10
+ /** The queries of the database check end before the check does, and give their connection back. */
11
+ const PROBE_TIMEOUT_MS = CHECK_TIMEOUT_MS - 1_000;
12
+ export function json(body, status) {
13
+ return new Response(JSON.stringify(body), {
14
+ status,
15
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
16
+ });
17
+ }
18
+ /**
19
+ * A page without its content security policy does not go out: the policy comes from `option` in
20
+ * sveltekit() of the product's vite.config.ts, and without it every page is a 500 that the delivery
21
+ * service sees, not a product that runs without the policy unnoticed. The log says it once.
22
+ */
23
+ export function policyGuard(option, noun) {
24
+ let said = false;
25
+ return (response, path) => {
26
+ const page = response.headers.get('content-type')?.startsWith('text/html') === true;
27
+ if (!page || response.status >= 500 || response.headers.has('content-security-policy')) {
28
+ return response;
29
+ }
30
+ if (!said) {
31
+ said = true;
32
+ log.error(`a page without its content security policy: ${option} of the baukasten is missing in sveltekit() of vite.config.ts, or an endpoint of the ${noun} answers HTML of its own; every such answer is a 500`, { path });
33
+ }
34
+ return new Response('Internal Error', {
35
+ status: 500,
36
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
37
+ });
38
+ };
39
+ }
40
+ export function serverCore(spec) {
41
+ const own = spec.hooks.checks ?? {};
42
+ if ('database' in own || 'mail' in own || spec.check in own) {
43
+ throw new Error(`the checks database, mail and ${spec.check} belong to the baukasten`);
44
+ }
45
+ let state = { kind: 'waiting' };
46
+ function running() {
47
+ switch (state.kind) {
48
+ case 'running':
49
+ return state;
50
+ case 'building':
51
+ throw new Error(`the baukasten is used in the build: its pages read the configuration of the running ${spec.noun} and cannot be prerendered`);
52
+ case 'waiting':
53
+ throw new Error('the baukasten is used before init ran');
54
+ }
55
+ }
56
+ async function stop(context, opened) {
57
+ await opened.runs?.stop();
58
+ try {
59
+ await spec.hooks.stop?.(context);
60
+ }
61
+ catch (error) {
62
+ process.exitCode = 1;
63
+ log.error(`the stop hook of the ${spec.noun} failed`, { error });
64
+ }
65
+ await context.db.close();
66
+ log.info('stopped');
67
+ }
68
+ async function start() {
69
+ const root = process.cwd();
70
+ let config;
71
+ try {
72
+ config = spec.load(root);
73
+ }
74
+ catch (error) {
75
+ if (!(error instanceof ConfigError))
76
+ throw error;
77
+ log.error('configuration incomplete, not starting', { problems: error.problems });
78
+ return exitAfterLog(1);
79
+ }
80
+ if (config.missing.length > 0) {
81
+ log.warn('running without what production needs', { missing: config.missing });
82
+ }
83
+ const db = createDb(config.databaseUrl, log);
84
+ const mail = createMailer(config.mail, config.name, log);
85
+ const limits = createLimits({ now: () => Date.now() });
86
+ const context = { config, db, log, mail, limits };
87
+ const opened = spec.open(context);
88
+ const sources = migrationSources(root, spec.art);
89
+ const probe = db.withTimeout(PROBE_TIMEOUT_MS);
90
+ const checks = Object.fromEntries(Object.entries(own).map(([name, check]) => [name, () => check(context)]));
91
+ const health = deepHealth({
92
+ checks: {
93
+ database: databaseCheck(probe, () => migrationStatus(probe, sources), log),
94
+ mail: mailCheck(mail, log),
95
+ [spec.check]: opened.check,
96
+ ...checks
97
+ },
98
+ now: () => Date.now(),
99
+ log
100
+ });
101
+ state = { kind: 'running', context, services: opened.services, health };
102
+ log.info('starting', {
103
+ mode: config.mode,
104
+ origin: config.publicOrigin,
105
+ language: config.language
106
+ });
107
+ opened.runs?.start();
108
+ await spec.hooks.start?.(context);
109
+ process.once('sveltekit:shutdown', () => void stop(context, opened));
110
+ }
111
+ const init = (building) => () => {
112
+ if (!building)
113
+ return start();
114
+ state = { kind: 'building' };
115
+ };
116
+ async function health(event) {
117
+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
118
+ return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
119
+ }
120
+ // Plain health answers without the database: Neon may sleep, the process still lives.
121
+ if (event.url.pathname === HEALTH)
122
+ return json({ status: 'ok' }, 200);
123
+ const report = await running().health();
124
+ return json(report, report.status === 'ok' ? 200 : 503);
125
+ }
126
+ const handle = async ({ event, resolve }) => {
127
+ // Prerendering in the build is no request of anyone.
128
+ if (state.kind === 'building')
129
+ return resolve(event);
130
+ const started = performance.now();
131
+ let status = 500;
132
+ try {
133
+ const path = event.url.pathname;
134
+ const response = path === HEALTH || path === DEEP
135
+ ? await health(event)
136
+ : await spec.respond(running(), event, resolve);
137
+ for (const [name, value] of Object.entries(spec.headers)) {
138
+ response.headers.set(name, value);
139
+ }
140
+ status = response.status;
141
+ return response;
142
+ }
143
+ finally {
144
+ // The container polls /health; it is logged only at debug. The path goes without its query,
145
+ // which may carry a token.
146
+ (event.url.pathname === HEALTH ? log.debug : log.info)('request', {
147
+ method: event.request.method,
148
+ path: event.url.pathname,
149
+ status,
150
+ ms: Math.round(performance.now() - started),
151
+ ...spec.logged?.(event)
152
+ });
153
+ }
154
+ };
155
+ const handleError = ({ error, event, status, message }) => {
156
+ // An unknown path is already in the request log.
157
+ if (status === 404)
158
+ return { message };
159
+ (status >= 500 ? log.error : log.warn)('request failed', {
160
+ method: event.request.method,
161
+ path: event.url.pathname,
162
+ status,
163
+ error
164
+ });
165
+ return { message: status >= 500 ? 'Internal Error' : message };
166
+ };
167
+ return { init, handle, handleError, running };
168
+ }
@@ -1,5 +1,5 @@
1
1
  import { StripeFailed } from './billing.js';
2
- import { catalogDifferences, foreignWebhooks } from './catalog.js';
2
+ import { catalogChanges, catalogPlan, foreignWebhooks } from './catalog.js';
3
3
  import { describeError } from './log.js';
4
4
  import { MailFailed } from './mail.js';
5
5
  export class CheckFailed extends Error {
@@ -97,7 +97,7 @@ export function stripeCheck(stripe, catalog, shop, log) {
97
97
  let foreign;
98
98
  try {
99
99
  [differences, foreign] = await Promise.all([
100
- catalogDifferences(stripe, catalog, shop),
100
+ catalogPlan(stripe, catalog, shop).then(catalogChanges),
101
101
  foreignWebhooks(stripe, shop.origin)
102
102
  ]);
103
103
  }
@@ -4,7 +4,7 @@ import type { Product, ProductEnv } from '../product.js';
4
4
  import { type Account } from './accounts.js';
5
5
  import { type CheckoutPage, type Order, type Purchase, type SubscriptionChange } from './billing.js';
6
6
  import { type Config } from './config.js';
7
- import { type Limits } from './limits.js';
7
+ import type { Limits } from './limits.js';
8
8
  import type { Log } from './log.js';
9
9
  import { type OpenApiDocument } from './openapi.js';
10
10
  import { type Pages } from './pages.js';
@@ -1,26 +1,21 @@
1
1
  import { createAccounts } from './accounts.js';
2
- import { createDb } from './adapter/db.js';
3
- import { exitAfterLog, log } from './adapter/log.js';
4
- import { createMailer } from './adapter/mailer.js';
5
- import { migrationSources, migrationStatus } from './adapter/migrate.js';
2
+ import { log } from './adapter/log.js';
6
3
  import { secrets } from './adapter/secrets.js';
7
4
  import { createStripe } from './adapter/stripe.js';
8
- import { asApiError, readBody, respond } from './api.js';
5
+ import { asApiError, readBody, respond as respondApi } from './api.js';
9
6
  import { createBilling, noBilling } from './billing.js';
10
- import { ConfigError, loadConfig } from './config.js';
7
+ import { loadConfig } from './config.js';
8
+ import { policyGuard, serverCore } from './core.js';
11
9
  import { apiAccount, serveApi } from './endpoints.js';
12
- import { CHECK_TIMEOUT_MS, databaseCheck, deepHealth, mailCheck, stripeCheck } from './health.js';
10
+ import { stripeCheck } from './health.js';
13
11
  import { SECURITY_HEADERS } from './headers.js';
14
12
  import { legalPages } from './legal/index.js';
15
- import { createLimits } from './limits.js';
16
13
  import { serveMachine } from './machine.js';
17
14
  import { MESSAGES } from './messages.js';
18
15
  import { mergeOpenApi, openApiPart } from './openapi.js';
19
16
  import { createPages } from './pages.js';
20
17
  import { BAUKASTEN_TERMS_DATE, termsVersion } from './terms.js';
21
18
  import { clearSessionCookie, clientAddress, isCrossSite, isMachinePath, SESSION_COOKIE } from './web.js';
22
- const HEALTH = '/health';
23
- const DEEP = '/health/deep';
24
19
  /**
25
20
  * The product's src/app.html writes `<html lang="%baukasten.lang%" %baukasten.theme%>`; the handle
26
21
  * fills in its language and, with a theme in its marks, `data-theme="…"`, else nothing.
@@ -31,59 +26,11 @@ const THEME = '%baukasten.theme%';
31
26
  function themeAttribute(theme) {
32
27
  return theme === undefined ? '' : `data-theme="${theme}"`;
33
28
  }
34
- /** The queries of the database check end before the check does, and give their connection back. */
35
- const PROBE_TIMEOUT_MS = CHECK_TIMEOUT_MS - 1_000;
36
- function json(body, status) {
37
- return new Response(JSON.stringify(body), {
38
- status,
39
- headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
40
- });
41
- }
42
29
  export function baukasten(product, hooks = {}) {
43
- const own = hooks.checks ?? {};
44
- if ('database' in own || 'mail' in own || 'stripe' in own) {
45
- throw new Error('the checks database, mail and stripe belong to the baukasten');
46
- }
47
- let state = { kind: 'waiting' };
48
- function running() {
49
- switch (state.kind) {
50
- case 'running':
51
- return state;
52
- case 'building':
53
- throw new Error('the baukasten is used in the build: its pages read the configuration of the running product and cannot be prerendered');
54
- case 'waiting':
55
- throw new Error('the baukasten is used before init ran');
56
- }
57
- }
58
- async function stop(context) {
59
- try {
60
- await hooks.stop?.(context);
61
- }
62
- catch (error) {
63
- process.exitCode = 1;
64
- log.error('the stop hook of the product failed', { error });
65
- }
66
- await context.db.close();
67
- log.info('stopped');
68
- }
69
- async function start() {
70
- let config;
71
- try {
72
- config = loadConfig(process.env, product);
73
- }
74
- catch (error) {
75
- if (!(error instanceof ConfigError))
76
- throw error;
77
- log.error('configuration incomplete, not starting', { problems: error.problems });
78
- return exitAfterLog(1);
79
- }
80
- if (config.missing.length > 0) {
81
- log.warn('running without what production needs', { missing: config.missing });
82
- }
83
- const db = createDb(config.databaseUrl, log);
84
- const mail = createMailer(config.mail, config.name, log);
85
- const limits = createLimits({ now: () => Date.now() });
86
- const context = { config, db, log, mail, limits };
30
+ const withPolicy = policyGuard('SVELTEKIT_OPTIONS', 'product');
31
+ /** Billing, accounts, legal pages and the rest, on the pool, mail and limits of the core. */
32
+ function open(context) {
33
+ const { config, db, mail, limits } = context;
87
34
  const version = termsVersion(BAUKASTEN_TERMS_DATE, product.termsDate);
88
35
  const messages = MESSAGES[config.language];
89
36
  const shop = { name: config.name, origin: config.publicOrigin };
@@ -133,52 +80,17 @@ export function baukasten(product, hooks = {}) {
133
80
  address: (event) => clientAddress(event.request.headers, config.mode, () => event.getClientAddress()),
134
81
  now: () => new Date()
135
82
  };
136
- const sources = migrationSources(process.cwd(), 'webapp');
137
- const probe = db.withTimeout(PROBE_TIMEOUT_MS);
138
- const checks = Object.fromEntries(Object.entries(own).map(([name, check]) => [name, () => check(context)]));
139
- // Without Stripe, locally, there is nothing to check.
140
- const stripeHealth = paid === null
141
- ? () => Promise.resolve()
142
- : stripeCheck(paid.stripe, product.catalog, shop, log);
143
- const health = deepHealth({
144
- checks: {
145
- database: databaseCheck(probe, () => migrationStatus(probe, sources), log),
146
- mail: mailCheck(mail, log),
147
- stripe: stripeHealth,
148
- ...checks
149
- },
150
- now: () => Date.now(),
151
- log
152
- });
153
- state = { kind: 'running', context, services, health };
154
- log.info('starting', {
155
- mode: config.mode,
156
- origin: config.publicOrigin,
157
- language: config.language
158
- });
159
- await hooks.start?.(context);
160
- process.once('sveltekit:shutdown', () => void stop(context));
161
- }
162
- const init = (building) => () => {
163
- if (!building)
164
- return start();
165
- state = { kind: 'building' };
166
- };
167
- async function health(event) {
168
- if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
169
- return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
170
- }
171
- // Plain health answers without the database: Neon may sleep, the process still lives.
172
- if (event.url.pathname === HEALTH)
173
- return json({ status: 'ok' }, 200);
174
- const report = await running().health();
175
- return json(report, report.status === 'ok' ? 200 : 503);
83
+ return {
84
+ services,
85
+ // Without Stripe, locally, there is nothing to check.
86
+ check: paid === null
87
+ ? () => Promise.resolve()
88
+ : stripeCheck(paid.stripe, product.catalog, shop, log)
89
+ };
176
90
  }
177
- async function respondTo(event, resolve) {
91
+ async function respond(r, event, resolve) {
178
92
  const path = event.url.pathname;
179
- if (path === HEALTH || path === DEEP)
180
- return health(event);
181
- const s = running().services;
93
+ const s = r.services;
182
94
  event.locals.account = null;
183
95
  if (isCrossSite(event.request, s.config.publicOrigin)) {
184
96
  log.warn('request refused: from another origin', { path });
@@ -208,86 +120,39 @@ export function baukasten(product, hooks = {}) {
208
120
  });
209
121
  return path.startsWith('/api/') ? asApiError(response, s.messages) : withPolicy(response, path);
210
122
  }
211
- /** Whether a page went out without its content security policy; said once in the log. */
212
- let policyMissing = false;
213
- /**
214
- * A page without its content security policy does not go out: the policy comes from
215
- * SVELTEKIT_OPTIONS in the configuration of the product, and without it every page is a 500 that
216
- * the delivery service sees, not a product that runs without the policy unnoticed.
217
- */
218
- function withPolicy(response, path) {
219
- const page = response.headers.get('content-type')?.startsWith('text/html') === true;
220
- if (!page || response.status >= 500 || response.headers.has('content-security-policy')) {
221
- return response;
222
- }
223
- if (!policyMissing) {
224
- policyMissing = true;
225
- log.error('a page without its content security policy: SVELTEKIT_OPTIONS of the baukasten is missing in sveltekit() of vite.config.ts, or an endpoint of the product answers HTML of its own; every such answer is a 500', { path });
226
- }
227
- return new Response('Internal Error', {
228
- status: 500,
229
- headers: { 'content-type': 'text/plain; charset=utf-8' }
230
- });
231
- }
232
- const handle = async ({ event, resolve }) => {
233
- // Prerendering in the build is no request of anyone.
234
- if (state.kind === 'building')
235
- return resolve(event);
236
- const started = performance.now();
237
- let status = 500;
238
- try {
239
- const response = await respondTo(event, resolve);
240
- for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
241
- response.headers.set(name, value);
242
- }
243
- status = response.status;
244
- return response;
245
- }
246
- finally {
247
- // The container polls /health; it is logged only at debug. The path goes without its query,
248
- // which may carry a token.
249
- (event.url.pathname === HEALTH ? log.debug : log.info)('request', {
250
- method: event.request.method,
251
- path: event.url.pathname,
252
- status,
253
- ms: Math.round(performance.now() - started)
254
- });
255
- }
256
- };
257
- const handleError = ({ error, event, status, message }) => {
258
- // An unknown path is already in the request log.
259
- if (status === 404)
260
- return { message };
261
- (status >= 500 ? log.error : log.warn)('request failed', {
262
- method: event.request.method,
263
- path: event.url.pathname,
264
- status,
265
- error
266
- });
267
- return { message: status >= 500 ? 'Internal Error' : message };
268
- };
123
+ const core = serverCore({
124
+ noun: 'product',
125
+ art: 'webapp',
126
+ check: 'stripe',
127
+ hooks,
128
+ load: () => loadConfig(process.env, product),
129
+ open,
130
+ respond,
131
+ headers: SECURITY_HEADERS
132
+ });
133
+ const services = () => core.running().services;
269
134
  return {
270
- init,
271
- handle,
272
- handleError,
273
- context: () => running().context,
274
- pages: createPages(() => running().services),
275
- clientAddress: (event) => running().services.address(event),
135
+ init: core.init,
136
+ handle: core.handle,
137
+ handleError: core.handleError,
138
+ context: () => core.running().context,
139
+ pages: createPages(services),
140
+ clientAddress: (event) => services().address(event),
276
141
  api: {
277
142
  respond: (handler) => {
278
- const s = running().services;
279
- return respond(s.log, s.messages, handler);
143
+ const s = services();
144
+ return respondApi(s.log, s.messages, handler);
280
145
  },
281
- body: (request, schema) => readBody(request, schema, running().services.messages),
282
- account: async (event) => (await apiAccount(running().services, event)).account
146
+ body: (request, schema) => readBody(request, schema, services().messages),
147
+ account: async (event) => (await apiAccount(services(), event)).account
283
148
  },
284
149
  openApi: (document) => {
285
- const s = running().services;
150
+ const s = services();
286
151
  return mergeOpenApi(document, openApiPart(s.config.publicOrigin, s.messages));
287
152
  },
288
153
  billing: {
289
- checkout: (account, order) => running().services.billing.checkout(account, order),
290
- portal: (account, back) => running().services.billing.portal(account, back)
154
+ checkout: (account, order) => services().billing.checkout(account, order),
155
+ portal: (account, back) => services().billing.portal(account, back)
291
156
  }
292
157
  };
293
158
  }
@@ -13,7 +13,10 @@ export declare class MailFailed extends Error {
13
13
  command?: string;
14
14
  });
15
15
  }
16
- /** Sends a mail of the accounts: true when it went out; a failure is logged with its kind. */
16
+ /**
17
+ * Sends a mail of the accounts: true when it went out, false when the relay did not take it, which
18
+ * is logged with its kind. Any other failure is a fault and goes on to the caller.
19
+ */
17
20
  export declare function deliver(mailer: Mailer, log: Log, mail: Mail, about: {
18
21
  kind: string;
19
22
  account: string;
@@ -15,15 +15,20 @@ export class MailFailed extends Error {
15
15
  this.code = details.code;
16
16
  }
17
17
  }
18
- /** Sends a mail of the accounts: true when it went out; a failure is logged with its kind. */
18
+ /**
19
+ * Sends a mail of the accounts: true when it went out, false when the relay did not take it, which
20
+ * is logged with its kind. Any other failure is a fault and goes on to the caller.
21
+ */
19
22
  export async function deliver(mailer, log, mail, about) {
20
23
  try {
21
24
  await mailer.send(mail);
22
- log.info('mail sent', about);
23
- return true;
24
25
  }
25
26
  catch (error) {
27
+ if (!(error instanceof MailFailed))
28
+ throw error;
26
29
  log.error('mail failed', { ...about, error });
27
30
  return false;
28
31
  }
32
+ log.info('mail sent', about);
33
+ return true;
29
34
  }
@@ -35,6 +35,7 @@ export interface Messages {
35
35
  mailFailed: string;
36
36
  account: {
37
37
  sameEmail: string;
38
+ notChanged: string;
38
39
  wrongPassword: string;
39
40
  wrongCurrent: string;
40
41
  taken: string;
@@ -23,6 +23,7 @@ const en = {
23
23
  mailFailed: 'We could not send the email. Please try again in a moment.',
24
24
  account: {
25
25
  sameEmail: 'This is your address already.',
26
+ notChanged: 'We could not change your address just now; it stays as it was. Please try again in a few minutes.',
26
27
  wrongPassword: 'The password is wrong.',
27
28
  wrongCurrent: 'The current password is wrong.',
28
29
  taken: 'Another account uses this address.',
@@ -137,6 +138,7 @@ const de = {
137
138
  mailFailed: 'Wir konnten die E-Mail nicht senden. Bitte versuchen Sie es gleich nochmals.',
138
139
  account: {
139
140
  sameEmail: 'Das ist schon Ihre Adresse.',
141
+ notChanged: 'Wir konnten Ihre Adresse gerade nicht ändern; sie bleibt, wie sie war. Bitte versuchen Sie es in einigen Minuten nochmals.',
140
142
  wrongPassword: 'Das Passwort ist falsch.',
141
143
  wrongCurrent: 'Das aktuelle Passwort ist falsch.',
142
144
  taken: 'Ein anderes Konto benutzt diese Adresse.',
@@ -1,6 +1,7 @@
1
1
  import { fail, redirect } from '@sveltejs/kit';
2
2
  import { z } from 'zod';
3
3
  import { EmailTaken, normalizeEmail, sha256 } from './accounts.js';
4
+ import { StripeFailed } from './billing.js';
4
5
  import { emailField, field, passwordField, problem } from './fields.js';
5
6
  import { LIMITS } from './limits.js';
6
7
  import { deliver } from './mail.js';
@@ -291,7 +292,20 @@ export function createPages(services) {
291
292
  actions: {
292
293
  default: async (event) => {
293
294
  const s = services();
294
- const result = await s.accounts.confirmEmailChange(field(await event.request.formData(), 'token'));
295
+ const token = field(await event.request.formData(), 'token');
296
+ let result;
297
+ try {
298
+ // Receipts and invoices follow the address, or the address does not change.
299
+ result = await s.accounts.confirmEmailChange(token, (tx, accountId, email) => s.billing.followEmail(tx, accountId, email));
300
+ }
301
+ catch (error) {
302
+ if (!(error instanceof StripeFailed))
303
+ throw error;
304
+ s.log.error('stripe: the customer could not take the new address; it did not change', {
305
+ error
306
+ });
307
+ return fail(503, { error: s.messages.account.notChanged });
308
+ }
295
309
  switch (result.kind) {
296
310
  case 'invalid':
297
311
  return fail(400, { error: s.messages.link.invalid });
@@ -305,15 +319,6 @@ export function createPages(services) {
305
319
  newEmail: result.newEmail
306
320
  });
307
321
  await deliver(s.mail, s.log, { to: result.oldEmail, ...notice }, { kind: 'email_changed', account: result.accountId });
308
- // The address has changed; receipts that still go to the old one are in the log.
309
- await s.billing
310
- .followEmail(result.accountId, result.newEmail)
311
- .catch((error) => {
312
- s.log.error('stripe: the customer kept the old address', {
313
- account: result.accountId,
314
- error
315
- });
316
- });
317
322
  const own = event.locals.account !== null && event.locals.account.id === result.accountId;
318
323
  redirect(303, own ? TO.accountEmailChanged : TO.loginEmailChanged);
319
324
  }
@@ -58,7 +58,7 @@ export type StripePrice = {
58
58
  product: string;
59
59
  /** In the smallest unit of the currency; null for a price that is not a fixed amount. */
60
60
  amount: number | null;
61
- /** Small letters, as Stripe writes it. */
61
+ /** Its code, like CHF. */
62
62
  currency: string;
63
63
  } & ({
64
64
  type: 'one_time';
@@ -79,6 +79,18 @@ export type NewPrice = {
79
79
  type: 'recurring';
80
80
  interval: 'month' | 'year';
81
81
  });
82
+ /** A product whose subscription a customer may change in the customer portal, and to which prices. */
83
+ export interface PortalProduct {
84
+ product: string;
85
+ prices: string[];
86
+ adjustable_quantity: {
87
+ enabled: false;
88
+ } | {
89
+ enabled: true;
90
+ minimum: number;
91
+ maximum: number;
92
+ };
93
+ }
82
94
  /** What the baukasten sets of the default configuration of the customer portal, as the API takes it. */
83
95
  export interface PortalConfiguration {
84
96
  business_profile: {
@@ -109,17 +121,7 @@ export interface PortalConfiguration {
109
121
  enabled: true;
110
122
  default_allowed_updates: ('price' | 'quantity')[];
111
123
  proration_behavior: 'create_prorations';
112
- products: {
113
- product: string;
114
- prices: string[];
115
- adjustable_quantity: {
116
- enabled: false;
117
- } | {
118
- enabled: true;
119
- minimum: number;
120
- maximum: number;
121
- };
122
- }[];
124
+ products: PortalProduct[];
123
125
  };
124
126
  };
125
127
  }
@@ -209,7 +211,7 @@ export type StripeEvent = {
209
211
  });
210
212
  /**
211
213
  * Stripe, with the key of the product. A failure throws StripeFailed (billing.ts), whose message
212
- * names what Stripe called it and never the key.
214
+ * names what Stripe called it and never the key. A currency is its code, like CHF, both ways.
213
215
  */
214
216
  export interface StripeApi {
215
217
  /** The product of this id, archived or not; null when there is none. */
@@ -255,8 +257,13 @@ export interface StripeApi {
255
257
  subscription(id: string): Promise<StripeSubscription>;
256
258
  /** Ends a subscription at once; `missing` when Stripe does not know it. */
257
259
  cancelSubscription(id: string): Promise<'canceled' | 'missing'>;
258
- /** Returns the address of the Checkout page. */
259
- createCheckout(request: CheckoutRequest): Promise<string>;
260
+ /** Returns the session and the address of its Checkout page. */
261
+ createCheckout(request: CheckoutRequest): Promise<{
262
+ id: string;
263
+ url: string;
264
+ }>;
265
+ /** Ends a Checkout that is still open, so that it can no longer be paid; `complete` if it was. */
266
+ retireCheckout(id: string): Promise<'expired' | 'complete'>;
260
267
  /** Returns the address of the customer portal. */
261
268
  createPortalSession(session: {
262
269
  customerId: string;
@@ -95,11 +95,12 @@ export async function salesNumbers(db, options) {
95
95
  OR EXISTS (SELECT 1 FROM baukasten.purchases p WHERE p.account_id = a.id AND p.amount > 0)
96
96
  )::int AS zahlende
97
97
  FROM baukasten.accounts a WHERE (NOT $1 OR a.stripe_mode = 'live') GROUP BY a.via`, [live]),
98
+ // The columns keep the currency in small letters, as Stripe writes it (003_stripe_and_sales.sql).
98
99
  db.query(`SELECT a.via, sum(paid.amount)::text AS cent FROM (
99
- SELECT account_id, amount FROM baukasten.invoices WHERE currency = $1
100
- UNION ALL SELECT account_id, amount FROM baukasten.purchases WHERE currency = $1
100
+ SELECT account_id, amount FROM baukasten.invoices WHERE currency = lower($1)
101
+ UNION ALL SELECT account_id, amount FROM baukasten.purchases WHERE currency = lower($1)
101
102
  ) paid JOIN baukasten.accounts a ON a.id = paid.account_id
102
- WHERE (NOT $2 OR a.stripe_mode = 'live') GROUP BY a.via`, [options.currency.toLowerCase(), live]),
103
+ WHERE (NOT $2 OR a.stripe_mode = 'live') GROUP BY a.via`, [options.currency, live]),
103
104
  db.query(`SELECT own.name, a.via, sum(own.value)::text AS value
104
105
  FROM unnest($1::text[], $2::uuid[], $3::bigint[]) AS own (name, account, value)
105
106
  JOIN baukasten.accounts a ON a.id = own.account