@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.
- package/dist/server/accounts.d.ts +6 -5
- package/dist/server/accounts.js +8 -6
- package/dist/server/adapter/migrate.js +16 -3
- package/dist/server/adapter/stripe.js +35 -12
- package/dist/server/baukasten-migrations.js +17 -0
- package/dist/server/billing.d.ts +2 -2
- package/dist/server/billing.js +83 -34
- package/dist/server/catalog.d.ts +44 -6
- package/dist/server/catalog.js +98 -79
- package/dist/server/config.d.ts +48 -5
- package/dist/server/config.js +94 -96
- package/dist/server/core.d.ts +88 -0
- package/dist/server/core.js +168 -0
- package/dist/server/health.js +2 -2
- package/dist/server/hook.d.ts +1 -1
- package/dist/server/hook.js +42 -177
- package/dist/server/mail.d.ts +4 -1
- package/dist/server/mail.js +8 -3
- package/dist/server/messages.d.ts +1 -0
- package/dist/server/messages.js +2 -0
- package/dist/server/pages.js +15 -10
- package/dist/server/ports.d.ts +22 -15
- package/dist/server/sales.js +4 -3
- package/dist/server/test-accounts.js +1 -1
- package/dist/shopify/config.js +21 -52
- package/dist/shopify/hook.d.ts +1 -1
- package/dist/shopify/hook.js +65 -188
- package/dist/testing/connection.js +0 -1
- package/dist/testing/env-example.js +3 -2
- package/dist/testing/shopify-connection.js +0 -1
- package/dist/testing/stripe.d.ts +2 -0
- package/dist/testing/stripe.js +35 -1
- package/dist/variables.d.ts +18 -9
- package/dist/variables.js +16 -7
- package/package.json +4 -1
|
@@ -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.
|
|
106
|
-
*
|
|
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
|
|
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. */
|
package/dist/server/accounts.js
CHANGED
|
@@ -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.
|
|
215
|
-
*
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
];
|
package/dist/server/billing.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/billing.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/server/catalog.d.ts
CHANGED
|
@@ -5,8 +5,38 @@ export interface Shop {
|
|
|
5
5
|
name: string;
|
|
6
6
|
origin: string;
|
|
7
7
|
}
|
|
8
|
-
|
|
9
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|