@tollwyn/baukasten 1.2.3 → 1.3.1

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.
@@ -102,10 +102,11 @@ export declare function createAccounts(db: Db, secrets: Secrets): {
102
102
  resetPassword(token: string, password: string): Promise<string | null>;
103
103
  /**
104
104
  * Makes the confirmed address the address of the account and voids every other open link of
105
- * the account, so that links sent to the old address stop working. An address another account
106
- * took in between leaves the link unused.
105
+ * the account, so that links sent to the old address stop working. `follow` brings the address
106
+ * to Stripe in the same transaction: when it fails, nothing changed and the link still holds.
107
+ * An address another account took in between leaves the link unused.
107
108
  */
108
- confirmEmailChange(token: string): Promise<EmailChange>;
109
+ confirmEmailChange(token: string, follow: (tx: Queryable, accountId: string, email: string) => Promise<void>): Promise<EmailChange>;
109
110
  createApiKey: (accountId: string, name: string, prefix: string) => Promise<{
110
111
  key: string;
111
112
  apiKey: ApiKey;
@@ -119,8 +120,8 @@ export declare function createAccounts(db: Db, secrets: Secrets): {
119
120
  } | null>;
120
121
  /**
121
122
  * Deletes what is personal in one transaction: first the data of the product through its hook,
122
- * then sessions, links and keys; the row stays without address and password, for the numbers
123
- * and the books. A failing hook changes nothing.
123
+ * then sessions, links, keys and an open Checkout; the row stays without address and password,
124
+ * for the numbers and the books. A failing hook changes nothing.
124
125
  */
125
126
  delete: (accountId: string, hook: DeleteHook) => Promise<void>;
126
127
  /** Removes expired sessions and links; it runs on login, never on a timer. */
@@ -211,10 +211,11 @@ export function createAccounts(db, secrets) {
211
211
  },
212
212
  /**
213
213
  * Makes the confirmed address the address of the account and voids every other open link of
214
- * the account, so that links sent to the old address stop working. An address another account
215
- * took in between leaves the link unused.
214
+ * the account, so that links sent to the old address stop working. `follow` brings the address
215
+ * to Stripe in the same transaction: when it fails, nothing changed and the link still holds.
216
+ * An address another account took in between leaves the link unused.
216
217
  */
217
- async confirmEmailChange(token) {
218
+ async confirmEmailChange(token, follow) {
218
219
  try {
219
220
  return await db.transaction(async (tx) => {
220
221
  const use = await consume(tx, token, 'change_email');
@@ -225,6 +226,7 @@ export function createAccounts(db, secrets) {
225
226
  await tx.query('DELETE FROM baukasten.links WHERE account_id = $1 AND used_at IS NULL', [
226
227
  use.accountId
227
228
  ]);
229
+ await follow(tx, use.accountId, use.newEmail);
228
230
  return {
229
231
  kind: 'changed',
230
232
  accountId: use.accountId,
@@ -259,15 +261,15 @@ export function createAccounts(db, secrets) {
259
261
  },
260
262
  /**
261
263
  * Deletes what is personal in one transaction: first the data of the product through its hook,
262
- * then sessions, links and keys; the row stays without address and password, for the numbers
263
- * and the books. A failing hook changes nothing.
264
+ * then sessions, links, keys and an open Checkout; the row stays without address and password,
265
+ * for the numbers and the books. A failing hook changes nothing.
264
266
  */
265
267
  delete: (accountId, hook) => db.transaction(async (tx) => {
266
268
  // The webhook of Stripe locks the account as well: one that comes now waits, and then
267
269
  // finds it deleted, instead of writing data of the product to it.
268
270
  await tx.query('SELECT 1 FROM baukasten.accounts WHERE id = $1 FOR UPDATE', [accountId]);
269
271
  await hook(tx, accountId);
270
- for (const table of ['sessions', 'links', 'api_keys']) {
272
+ for (const table of ['sessions', 'links', 'api_keys', 'checkouts']) {
271
273
  await tx.query(`DELETE FROM baukasten.${table} WHERE account_id = $1`, [accountId]);
272
274
  }
273
275
  await tx.query(`UPDATE baukasten.accounts SET email = NULL, password_hash = NULL,
@@ -61,6 +61,9 @@ class MigrationError extends Error {
61
61
  }
62
62
  export async function migrate(databaseUrl, sources) {
63
63
  const client = new pg.Client({ connectionString: databaseUrl });
64
+ // A broken connection rejects the query that runs and every one after it, which reports it; the
65
+ // event of the client says the same, and without a listener it would end the process first.
66
+ client.on('error', () => undefined);
64
67
  await client.connect();
65
68
  const db = {
66
69
  query: async (sql, params) => (await client.query(sql, params)).rows
@@ -95,6 +98,7 @@ export async function migrate(databaseUrl, sources) {
95
98
  await client.end();
96
99
  }
97
100
  }
101
+ const reasonOf = (error) => (error instanceof Error ? error.message : String(error));
98
102
  async function apply(client, layer, file) {
99
103
  await client.query('BEGIN');
100
104
  try {
@@ -106,8 +110,17 @@ async function apply(client, layer, file) {
106
110
  await client.query('COMMIT');
107
111
  }
108
112
  catch (error) {
109
- await client.query('ROLLBACK');
110
- const reason = error instanceof Error ? error.message : String(error);
111
- throw new MigrationError(`${layer} migration ${file.name} failed: ${reason}`, { cause: error });
113
+ const failed = `${layer} migration ${file.name} failed: ${reasonOf(error)}`;
114
+ try {
115
+ await client.query('ROLLBACK');
116
+ }
117
+ catch (rollback) {
118
+ // The connection broke with the file: the server ends the transaction with it, and the error
119
+ // of the file is the one that says why.
120
+ throw new MigrationError(`${failed}; its rollback failed as well: ${reasonOf(rollback)}`, {
121
+ cause: error
122
+ });
123
+ }
124
+ throw new MigrationError(failed, { cause: error });
112
125
  }
113
126
  }
@@ -24,6 +24,11 @@ async function call(what, request) {
24
24
  throw error;
25
25
  }
26
26
  }
27
+ /**
28
+ * Stripe writes a currency in small letters, the baukasten and its catalog write its code, like CHF:
29
+ * here, coming in, and in small letters again on the way out.
30
+ */
31
+ const codeOf = (currency) => currency.toUpperCase();
27
32
  const isMissing = (error) => error instanceof StripeFailed && error.kind === 'resource_missing';
28
33
  const idOf = (value) => (typeof value === 'string' ? value : value.id);
29
34
  function priceOf(price) {
@@ -31,7 +36,7 @@ function priceOf(price) {
31
36
  id: price.id,
32
37
  product: idOf(price.product),
33
38
  amount: price.unit_amount,
34
- currency: price.currency
39
+ currency: codeOf(price.currency)
35
40
  };
36
41
  return price.recurring === null
37
42
  ? { ...base, type: 'one_time' }
@@ -110,7 +115,7 @@ function sessionOf(raw) {
110
115
  customerId: customer,
111
116
  paid: raw.payment_status === 'paid',
112
117
  amount: raw.amount_total,
113
- currency: raw.currency,
118
+ currency: codeOf(raw.currency),
114
119
  product,
115
120
  lookupKey
116
121
  };
@@ -145,7 +150,7 @@ function eventOf(payload) {
145
150
  id: invoice.id,
146
151
  customerId: invoice.customer,
147
152
  amount: invoice.amount_paid,
148
- currency: invoice.currency,
153
+ currency: codeOf(invoice.currency),
149
154
  // A paid invoice has its time of payment; the event is as late.
150
155
  paidAt: at(invoice.status_transitions.paid_at ?? event.created)
151
156
  }
@@ -188,7 +193,7 @@ export function createStripe(config) {
188
193
  async createPrice(price) {
189
194
  const created = await call('creating a price', () => stripe.prices.create({
190
195
  product: price.product,
191
- currency: price.currency,
196
+ currency: price.currency.toLowerCase(),
192
197
  unit_amount: price.amount,
193
198
  lookup_key: price.lookupKey,
194
199
  transfer_lookup_key: true,
@@ -227,13 +232,19 @@ export function createStripe(config) {
227
232
  async updateCustomerEmail(customerId, email) {
228
233
  await call('updating a customer', () => stripe.customers.update(customerId, { email }));
229
234
  },
230
- async subscriptionsOf(customerId) {
231
- const { data } = await call('reading subscriptions', () => stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 100 }));
232
- return data.map((subscription) => ({
233
- id: subscription.id,
234
- status: statusOf(subscription.status)
235
- }));
236
- },
235
+ subscriptionsOf: (customerId) => call('reading subscriptions', async () => {
236
+ const all = [];
237
+ // Page by page: the list gives at most 100 at a time.
238
+ const pages = stripe.subscriptions.list({
239
+ customer: customerId,
240
+ status: 'all',
241
+ limit: 100
242
+ });
243
+ for await (const subscription of pages) {
244
+ all.push({ id: subscription.id, status: statusOf(subscription.status) });
245
+ }
246
+ return all;
247
+ }),
237
248
  async subscription(id) {
238
249
  const found = await call('reading a subscription', () => stripe.subscriptions.retrieve(id));
239
250
  // The baukasten opens subscriptions of one price; the first item is that price.
@@ -305,7 +316,19 @@ export function createStripe(config) {
305
316
  }));
306
317
  if (session.url === null)
307
318
  throw new StripeFailed('no_url', 'stripe: Checkout gave no address');
308
- return session.url;
319
+ return { id: session.id, url: session.url };
320
+ },
321
+ async retireCheckout(id) {
322
+ const { status } = await call('reading Checkout', () => stripe.checkout.sessions.retrieve(id));
323
+ if (status === 'complete')
324
+ return 'complete';
325
+ if (status === 'open') {
326
+ await call('ending Checkout', () => stripe.checkout.sessions.expire(id));
327
+ }
328
+ else if (status !== 'expired') {
329
+ throw new StripeFailed('unexpected_status', `stripe: a Checkout with the status ${String(status)}`);
330
+ }
331
+ return 'expired';
309
332
  },
310
333
  async createPortalSession(session) {
311
334
  const created = await call('opening the customer portal', () => stripe.billingPortal.sessions.create({
@@ -121,6 +121,23 @@ CREATE TABLE baukasten.purchases (
121
121
  paid_at timestamptz NOT NULL
122
122
  );
123
123
  CREATE INDEX purchases_account ON baukasten.purchases (account_id);
124
+ `
125
+ },
126
+ {
127
+ // The version before neither reads nor writes the table; it deletes accounts of the test mode
128
+ // without knowing it, so a row goes with its account.
129
+ name: '004_open_checkouts.sql',
130
+ sql: `-- The open Checkout of a subscription of an account, with the price at Stripe and the quantity it
131
+ -- was opened for: a second click gets the same page instead of a second subscription. The webhook
132
+ -- removes it once it is paid; one for another price or quantity replaces it.
133
+ CREATE TABLE baukasten.checkouts (
134
+ account_id uuid PRIMARY KEY REFERENCES baukasten.accounts (id) ON DELETE CASCADE,
135
+ session text NOT NULL UNIQUE,
136
+ url text NOT NULL,
137
+ price text NOT NULL,
138
+ quantity integer NOT NULL CHECK (quantity >= 1),
139
+ expires_at timestamptz NOT NULL
140
+ );
124
141
  `
125
142
  }
126
143
  ];
@@ -75,8 +75,8 @@ export interface Billing {
75
75
  receive(payload: string, signature: string | null): Promise<Received>;
76
76
  /** Ends every subscription of the account at once, as when it is deleted. */
77
77
  endSubscriptions(accountId: string): Promise<void>;
78
- /** Receipts and invoices follow a new login address. */
79
- followEmail(accountId: string, email: string): Promise<void>;
78
+ /** Receipts and invoices follow a new login address; `q` is the transaction of the change. */
79
+ followEmail(q: Queryable, accountId: string, email: string): Promise<void>;
80
80
  }
81
81
  /** Billing without Stripe, locally: an account that is no customer has nothing to end or follow. */
82
82
  export declare function noBilling(db: Queryable): Billing;
@@ -10,8 +10,10 @@ export class StripeFailed extends Error {
10
10
  }
11
11
  const ENDED = ['canceled', 'incomplete_expired'];
12
12
  const running = (status) => !ENDED.includes(status);
13
- /** Stripe keeps a Checkout open for at least this long; one from another tab expires soon after. */
13
+ /** Stripe keeps a Checkout open for at least 30 minutes. */
14
14
  const CHECKOUT_OPEN_MS = 31 * 60_000;
15
+ /** An open Checkout goes to a second click only while this much of it is left to pay in. */
16
+ const CHECKOUT_LEFT_MS = 10 * 60_000;
15
17
  const OWN_PATH = /^\/(?!\/)[^\s\\]*$/;
16
18
  function ownPath(path, what) {
17
19
  if (!OWN_PATH.test(path))
@@ -28,8 +30,8 @@ async function customerOf(db, accountId) {
28
30
  }
29
31
  /** Billing without Stripe, locally: an account that is no customer has nothing to end or follow. */
30
32
  export function noBilling(db) {
31
- const without = async (accountId) => {
32
- if ((await customerOf(db, accountId)).id !== null) {
33
+ const without = async (q, accountId) => {
34
+ if ((await customerOf(q, accountId)).id !== null) {
33
35
  throw new Error(`the account is a customer at Stripe, but ${NOT_CONFIGURED}`);
34
36
  }
35
37
  };
@@ -38,13 +40,13 @@ export function noBilling(db) {
38
40
  portal: () => Promise.reject(new Error(NOT_CONFIGURED)),
39
41
  hasCustomer: async (accountId) => (await customerOf(db, accountId)).id !== null,
40
42
  receive: () => Promise.reject(new Error(NOT_CONFIGURED)),
41
- endSubscriptions: without,
42
- followEmail: without
43
+ endSubscriptions: (accountId) => without(db, accountId),
44
+ followEmail: (q, accountId) => without(q, accountId)
43
45
  };
44
46
  }
45
47
  export function createBilling(options) {
46
48
  const { db, stripe, catalog, shop, mode, hooks, log, now } = options;
47
- const currency = catalog.currency.toLowerCase();
49
+ const { currency } = catalog;
48
50
  const prices = new Map(pricesOf(catalog).map((price) => [price.lookupKey, price]));
49
51
  const products = new Map(catalog.products.map((product) => [product.id, product]));
50
52
  const priceIds = new Map();
@@ -76,13 +78,13 @@ export function createBilling(options) {
76
78
  * one first: the stored one wins. An account that gets its customer with the live key is live from
77
79
  * then on; one from the test mode has customers of the test mode only.
78
80
  */
79
- async function customer(account) {
81
+ async function customer(q, account) {
80
82
  // A customer of the other mode is out of reach of this key: Stripe says so loudly.
81
- const known = (await customerOf(db, account.id)).id;
83
+ const known = (await customerOf(q, account.id)).id;
82
84
  if (known !== null)
83
85
  return known;
84
86
  const created = await stripe.createCustomer({ email: account.email, accountId: account.id });
85
- const [row] = await db.query(`UPDATE baukasten.accounts SET
87
+ const [row] = await q.query(`UPDATE baukasten.accounts SET
86
88
  stripe_mode = CASE WHEN stripe_customer_id IS NULL AND $3 THEN 'live' ELSE stripe_mode END,
87
89
  stripe_customer_id = coalesce(stripe_customer_id, $2)
88
90
  WHERE id = $1 AND deleted_at IS NULL
@@ -91,6 +93,55 @@ export function createBilling(options) {
91
93
  throw new Error('the account was deleted');
92
94
  return row.stripe_customer_id;
93
95
  }
96
+ /** The Checkout of a subscription that each account waits for in this process. */
97
+ const checkouts = new Map();
98
+ /**
99
+ * Opens the Checkouts of an account in this process one after the other, each in a transaction of
100
+ * its own: a click that waits for another holds no connection of the pool, and the lock of the
101
+ * account row still orders those of another process.
102
+ */
103
+ function inTurn(accountId, open) {
104
+ const before = checkouts.get(accountId) ?? Promise.resolve(null);
105
+ // Whatever became of the one before, its caller has it; this one runs after it.
106
+ const mine = before.then(open, open);
107
+ checkouts.set(accountId, mine);
108
+ const done = () => {
109
+ if (checkouts.get(accountId) === mine)
110
+ checkouts.delete(accountId);
111
+ };
112
+ void mine.then(done, done);
113
+ return mine;
114
+ }
115
+ /**
116
+ * A Checkout of a subscription, under the lock of the account: a second click gets the page of the
117
+ * first, so that only one can be paid, and one for another price or quantity ends the open one
118
+ * first. A running subscription gets none; the customer portal changes it.
119
+ */
120
+ async function subscriptionCheckout(tx, account, request) {
121
+ await tx.query('SELECT 1 FROM baukasten.accounts WHERE id = $1 FOR UPDATE', [account.id]);
122
+ const customerId = await customer(tx, account);
123
+ const subscribed = async () => (await stripe.subscriptionsOf(customerId)).some((s) => running(s.status));
124
+ if (await subscribed())
125
+ return { kind: 'subscribed' };
126
+ const [open] = await tx.query('SELECT session, url, price, quantity, expires_at FROM baukasten.checkouts WHERE account_id = $1', [account.id]);
127
+ if (open !== undefined) {
128
+ const same = open.price === request.priceId &&
129
+ open.quantity === request.quantity &&
130
+ open.expires_at.getTime() - now().getTime() >= CHECKOUT_LEFT_MS;
131
+ if (same)
132
+ return { kind: 'open', url: open.url };
133
+ // Paid in another tab in the meantime: its subscription runs now.
134
+ if ((await stripe.retireCheckout(open.session)) === 'complete' && (await subscribed())) {
135
+ return { kind: 'subscribed' };
136
+ }
137
+ }
138
+ const created = await stripe.createCheckout({ ...request, customerId });
139
+ await tx.query(`INSERT INTO baukasten.checkouts (account_id, session, url, price, quantity, expires_at)
140
+ VALUES ($1, $2, $3, $4, $5, $6)
141
+ ON CONFLICT (account_id) DO UPDATE SET session = excluded.session, url = excluded.url,
142
+ price = excluded.price, quantity = excluded.quantity, expires_at = excluded.expires_at`, [account.id, created.id, created.url, request.priceId, request.quantity, request.expiresAt]);
143
+ return { kind: 'open', url: created.url };
144
+ }
94
145
  function productOf(price) {
95
146
  const product = products.get(price.product);
96
147
  if (product === undefined)
@@ -102,8 +153,8 @@ export function createBilling(options) {
102
153
  * key does not reach, and its subscriptions move no money. A live customer under the test key, as
103
154
  * after a switch to live taken back, is asked for and fails loudly: its subscriptions move money.
104
155
  */
105
- async function reachable(accountId) {
106
- const customer = await customerOf(db, accountId);
156
+ async function reachable(q, accountId) {
157
+ const customer = await customerOf(q, accountId);
107
158
  if (customer.id === null)
108
159
  return { kind: 'none' };
109
160
  if (customer.mode === 'test' && mode === 'live')
@@ -111,8 +162,8 @@ export function createBilling(options) {
111
162
  return { kind: 'customer', id: customer.id };
112
163
  }
113
164
  /** The customer to act on at Stripe; one of the test mode under the live key is left alone. */
114
- async function actOn(accountId) {
115
- const customer = await reachable(accountId);
165
+ async function actOn(q, accountId) {
166
+ const customer = await reachable(q, accountId);
116
167
  switch (customer.kind) {
117
168
  case 'customer':
118
169
  return customer.id;
@@ -191,8 +242,9 @@ export function createBilling(options) {
191
242
  if (account === null)
192
243
  return;
193
244
  otherCurrency(event, session.currency);
245
+ // The column keeps the currency in small letters, as Stripe writes it (003_stripe_and_sales.sql).
194
246
  const inserted = await tx.query(`INSERT INTO baukasten.purchases (id, account_id, product, price, amount, currency, paid_at)
195
- VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING RETURNING id`, [
247
+ VALUES ($1, $2, $3, $4, $5, lower($6), $7) ON CONFLICT (id) DO NOTHING RETURNING id`, [
196
248
  session.id,
197
249
  account.id,
198
250
  session.product,
@@ -210,14 +262,18 @@ export function createBilling(options) {
210
262
  product: session.product,
211
263
  price: session.lookupKey,
212
264
  amount: session.amount,
213
- currency: session.currency.toUpperCase(),
265
+ currency: session.currency,
214
266
  paidAt: event.created
215
267
  });
216
268
  }
217
269
  async function checkoutCompleted(tx, event, session) {
218
270
  switch (session.mode) {
219
271
  case 'subscription':
220
- return syncSubscription(tx, event, session.customerId, session.subscriptionId);
272
+ await syncSubscription(tx, event, session.customerId, session.subscriptionId);
273
+ // Paid: the next Checkout of the account is a new one. After the lock of the account, in
274
+ // the order the Checkout takes them.
275
+ await tx.query('DELETE FROM baukasten.checkouts WHERE session = $1', [session.id]);
276
+ return;
221
277
  case 'payment':
222
278
  return purchasePaid(tx, event, session);
223
279
  case 'other':
@@ -241,7 +297,7 @@ export function createBilling(options) {
241
297
  return;
242
298
  otherCurrency(event, invoice.currency);
243
299
  await tx.query(`INSERT INTO baukasten.invoices (id, account_id, amount, currency, paid_at)
244
- VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO NOTHING`, [invoice.id, account.id, invoice.amount, invoice.currency, invoice.paidAt]);
300
+ VALUES ($1, $2, $3, lower($4), $5) ON CONFLICT (id) DO NOTHING`, [invoice.id, account.id, invoice.amount, invoice.currency, invoice.paidAt]);
245
301
  return;
246
302
  }
247
303
  case 'other':
@@ -269,32 +325,25 @@ export function createBilling(options) {
269
325
  throw new Error(`the quantity of ${order.price} is 1 to ${max}, not ${quantity}`);
270
326
  }
271
327
  const stripePrice = await priceId(wanted);
272
- const customerId = await customer(account);
273
- // A repeated click finds the running subscription. Two Checkouts open at the same time
274
- // can still make two subscriptions; that each expires soon only narrows the window.
275
- const open = (await stripe.subscriptionsOf(customerId)).filter((s) => running(s.status));
276
- if (open.length > 0)
277
- return { kind: 'subscribed' };
278
- const url = await stripe.createCheckout({
328
+ const request = {
279
329
  ...base,
280
- customerId,
281
330
  priceId: stripePrice,
282
331
  mode: 'subscription',
283
332
  quantity,
284
333
  adjustable: product.quantity === undefined
285
334
  ? { kind: 'fixed' }
286
335
  : { kind: 'up to', max: product.quantity.max }
287
- });
288
- return { kind: 'open', url };
336
+ };
337
+ return inTurn(account.id, () => db.transaction((tx) => subscriptionCheckout(tx, account, request)));
289
338
  }
290
339
  case 'purchase': {
291
340
  if (order.quantity !== undefined && order.quantity !== 1) {
292
341
  throw new Error(`a purchase of ${order.price} is one, not ${order.quantity}`);
293
342
  }
294
343
  const stripePrice = await priceId(wanted);
295
- const url = await stripe.createCheckout({
344
+ const { url } = await stripe.createCheckout({
296
345
  ...base,
297
- customerId: await customer(account),
346
+ customerId: await customer(db, account),
298
347
  priceId: stripePrice,
299
348
  mode: 'payment',
300
349
  product: product.id,
@@ -307,12 +356,12 @@ export function createBilling(options) {
307
356
  async portal(account, back) {
308
357
  const configurationId = await portalConfiguration();
309
358
  return stripe.createPortalSession({
310
- customerId: await customer(account),
359
+ customerId: await customer(db, account),
311
360
  configurationId,
312
361
  returnUrl: `${shop.origin}${ownPath(back, 'back')}`
313
362
  });
314
363
  },
315
- hasCustomer: async (accountId) => (await reachable(accountId)).kind === 'customer',
364
+ hasCustomer: async (accountId) => (await reachable(db, accountId)).kind === 'customer',
316
365
  async receive(payload, signature) {
317
366
  const read = stripe.event(payload, signature);
318
367
  if (read.kind === 'invalid')
@@ -332,7 +381,7 @@ export function createBilling(options) {
332
381
  : { kind: 'repeated', type: event.type };
333
382
  },
334
383
  async endSubscriptions(accountId) {
335
- const customerId = await actOn(accountId);
384
+ const customerId = await actOn(db, accountId);
336
385
  if (customerId === null)
337
386
  return;
338
387
  for (const subscription of await stripe.subscriptionsOf(customerId)) {
@@ -340,8 +389,8 @@ export function createBilling(options) {
340
389
  await stripe.cancelSubscription(subscription.id);
341
390
  }
342
391
  },
343
- async followEmail(accountId, email) {
344
- const customerId = await actOn(accountId);
392
+ async followEmail(q, accountId, email) {
393
+ const customerId = await actOn(q, accountId);
345
394
  if (customerId !== null)
346
395
  await stripe.updateCustomerEmail(customerId, email);
347
396
  }
@@ -5,8 +5,38 @@ export interface Shop {
5
5
  name: string;
6
6
  origin: string;
7
7
  }
8
- export interface CatalogStep {
9
- subject: 'product' | 'price' | 'portal';
8
+ /**
9
+ * What setting the catalog up does with one thing at Stripe: create it, update it, or leave it as it
10
+ * exists. A price is never changed: `update` makes a new one that takes the lookup key over.
11
+ */
12
+ export type CatalogStep = {
13
+ subject: 'product';
14
+ id: string;
15
+ kind: 'create' | 'update' | 'exists';
16
+ name: string;
17
+ } | ({
18
+ subject: 'price';
19
+ id: string;
20
+ price: NewPrice;
21
+ } & ({
22
+ kind: 'create' | 'update';
23
+ } | {
24
+ kind: 'exists';
25
+ stripeId: string;
26
+ })) | {
27
+ subject: 'portal';
28
+ id: string;
29
+ kind: 'update' | 'exists';
30
+ }
31
+ /** Only the Dashboard creates the default configuration of the customer portal. */
32
+ | {
33
+ subject: 'portal';
34
+ id: null;
35
+ kind: 'missing';
36
+ };
37
+ /** What setting the catalog up did with one thing at Stripe, as the migration command logs it. */
38
+ export interface CatalogAction {
39
+ subject: CatalogStep['subject'];
10
40
  id: string;
11
41
  action: 'created' | 'updated' | 'exists';
12
42
  }
@@ -15,9 +45,10 @@ export declare function pricesOf(catalog: Catalog): NewPrice[];
15
45
  export declare function priceFits(found: StripePrice, wanted: NewPrice): boolean;
16
46
  /**
17
47
  * The default configuration of the customer portal names the product and links its own pages: an
18
- * account copied from another one brings that one's along.
48
+ * account copied from another one brings that one's along. `ids` holds the price at Stripe of each
49
+ * lookup key that has one; null while a price the portal offers has none yet.
19
50
  */
20
- export declare function portalConfiguration(catalog: Catalog, shop: Shop, priceId: (lookupKey: string) => string): PortalConfiguration;
51
+ export declare function portalConfiguration(catalog: Catalog, shop: Shop, ids: ReadonlyMap<string, string>): PortalConfiguration | null;
21
52
  /**
22
53
  * Whether `found` holds every value of `wanted`. Lists hold the same items in any order: Stripe
23
54
  * need not keep the order it was given.
@@ -25,9 +56,16 @@ export declare function portalConfiguration(catalog: Catalog, shop: Shop, priceI
25
56
  export declare function contains(found: unknown, wanted: unknown): boolean;
26
57
  /** Only the Dashboard creates the default configuration of the customer portal. */
27
58
  export declare const NO_DEFAULT_PORTAL = "the Stripe account has no default configuration of the customer portal in this mode: save the settings of the customer portal once in the Stripe Dashboard, then migrate again";
28
- export declare function setUpCatalog(stripe: StripeApi, catalog: Catalog, shop: Shop): Promise<CatalogStep[]>;
59
+ /**
60
+ * The catalog compared with Stripe: products first, then prices, then the portal, which offers the
61
+ * prices. A price still to be made changes a portal that offers it: its new id is in no
62
+ * configuration yet.
63
+ */
64
+ export declare function catalogPlan(stripe: StripeApi, catalog: Catalog, shop: Shop): Promise<CatalogStep[]>;
29
65
  /** What the migration would change at Stripe, as `product <id>`, `price <key>` or `portal`. */
30
- export declare function catalogDifferences(stripe: StripeApi, catalog: Catalog, shop: Shop): Promise<string[]>;
66
+ export declare function catalogChanges(plan: readonly CatalogStep[]): string[];
67
+ /** Carries the plan out: a second run finds everything as it exists and changes nothing. */
68
+ export declare function setUpCatalog(stripe: StripeApi, catalog: Catalog, shop: Shop): Promise<CatalogAction[]>;
31
69
  /**
32
70
  * The enabled webhook endpoints of the Stripe account that are not this product's: an account copied
33
71
  * from another product brings that one's along, and they would get the events of this one. Named by