@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.
- package/README.md +254 -1
- package/dist/bin/check.d.ts +2 -0
- package/dist/bin/check.js +38 -0
- package/dist/bin/migrate.d.ts +2 -0
- package/dist/bin/migrate.js +111 -0
- package/dist/bin/stubs.d.ts +2 -0
- package/dist/bin/stubs.js +38 -0
- package/dist/bin/test-accounts.d.ts +2 -0
- package/dist/bin/test-accounts.js +62 -0
- package/dist/check.d.ts +20 -0
- package/dist/check.js +285 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- package/dist/product.d.ts +161 -0
- package/dist/product.js +152 -0
- package/dist/server/accounts.d.ts +128 -0
- package/dist/server/accounts.js +282 -0
- package/dist/server/adapter/db.d.ts +5 -0
- package/dist/server/adapter/db.js +63 -0
- package/dist/server/adapter/log.d.ts +3 -0
- package/dist/server/adapter/log.js +12 -0
- package/dist/server/adapter/mailer.d.ts +4 -0
- package/dist/server/adapter/mailer.js +56 -0
- package/dist/server/adapter/migrate.d.ts +21 -0
- package/dist/server/adapter/migrate.js +113 -0
- package/dist/server/adapter/secrets.d.ts +2 -0
- package/dist/server/adapter/secrets.js +40 -0
- package/dist/server/adapter/stripe.d.ts +11 -0
- package/dist/server/adapter/stripe.js +333 -0
- package/dist/server/api.d.ts +25 -0
- package/dist/server/api.js +98 -0
- package/dist/server/baukasten-migrations.d.ts +4 -0
- package/dist/server/baukasten-migrations.js +126 -0
- package/dist/server/billing.d.ts +94 -0
- package/dist/server/billing.js +349 -0
- package/dist/server/catalog.d.ts +39 -0
- package/dist/server/catalog.js +193 -0
- package/dist/server/config.d.ts +108 -0
- package/dist/server/config.js +244 -0
- package/dist/server/endpoints.d.ts +44 -0
- package/dist/server/endpoints.js +159 -0
- package/dist/server/fields.d.ts +7 -0
- package/dist/server/fields.js +18 -0
- package/dist/server/headers.d.ts +1 -0
- package/dist/server/headers.js +9 -0
- package/dist/server/health.d.ts +34 -0
- package/dist/server/health.js +116 -0
- package/dist/server/hook.d.ts +92 -0
- package/dist/server/hook.js +293 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.js +11 -0
- package/dist/server/legal/de.d.ts +2 -0
- package/dist/server/legal/de.js +251 -0
- package/dist/server/legal/document.d.ts +37 -0
- package/dist/server/legal/document.js +30 -0
- package/dist/server/legal/en.d.ts +2 -0
- package/dist/server/legal/en.js +251 -0
- package/dist/server/legal/fleet.d.ts +114 -0
- package/dist/server/legal/fleet.js +89 -0
- package/dist/server/legal/index.d.ts +3 -0
- package/dist/server/legal/index.js +11 -0
- package/dist/server/limits.d.ts +58 -0
- package/dist/server/limits.js +76 -0
- package/dist/server/log.d.ts +26 -0
- package/dist/server/log.js +61 -0
- package/dist/server/machine.d.ts +4 -0
- package/dist/server/machine.js +57 -0
- package/dist/server/mail.d.ts +20 -0
- package/dist/server/mail.js +29 -0
- package/dist/server/messages.d.ts +105 -0
- package/dist/server/messages.js +232 -0
- package/dist/server/migrations.d.ts +28 -0
- package/dist/server/migrations.js +59 -0
- package/dist/server/openapi.d.ts +24 -0
- package/dist/server/openapi.js +173 -0
- package/dist/server/pages.d.ts +158 -0
- package/dist/server/pages.js +491 -0
- package/dist/server/ports.d.ts +273 -0
- package/dist/server/ports.js +1 -0
- package/dist/server/runs.d.ts +30 -0
- package/dist/server/runs.js +55 -0
- package/dist/server/sales.d.ts +38 -0
- package/dist/server/sales.js +132 -0
- package/dist/server/services.d.ts +57 -0
- package/dist/server/services.js +44 -0
- package/dist/server/start.d.ts +14 -0
- package/dist/server/start.js +65 -0
- package/dist/server/stripe.d.ts +33 -0
- package/dist/server/stripe.js +29 -0
- package/dist/server/terms.d.ts +7 -0
- package/dist/server/terms.js +9 -0
- package/dist/server/test-accounts.d.ts +28 -0
- package/dist/server/test-accounts.js +62 -0
- package/dist/server/via.d.ts +9 -0
- package/dist/server/via.js +15 -0
- package/dist/server/web.d.ts +48 -0
- package/dist/server/web.js +105 -0
- package/dist/shopify/adapter/shopify.d.ts +8 -0
- package/dist/shopify/adapter/shopify.js +232 -0
- package/dist/shopify/admin.d.ts +33 -0
- package/dist/shopify/admin.js +47 -0
- package/dist/shopify/app.d.ts +60 -0
- package/dist/shopify/app.js +67 -0
- package/dist/shopify/config.d.ts +53 -0
- package/dist/shopify/config.js +133 -0
- package/dist/shopify/constants.d.ts +27 -0
- package/dist/shopify/constants.js +27 -0
- package/dist/shopify/headers.d.ts +1 -0
- package/dist/shopify/headers.js +8 -0
- package/dist/shopify/hmac.d.ts +1 -0
- package/dist/shopify/hmac.js +9 -0
- package/dist/shopify/hook.d.ts +60 -0
- package/dist/shopify/hook.js +508 -0
- package/dist/shopify/id-token.d.ts +12 -0
- package/dist/shopify/id-token.js +66 -0
- package/dist/shopify/index.d.ts +19 -0
- package/dist/shopify/index.js +15 -0
- package/dist/shopify/legal/de.d.ts +3 -0
- package/dist/shopify/legal/de.js +123 -0
- package/dist/shopify/legal/en.d.ts +3 -0
- package/dist/shopify/legal/en.js +121 -0
- package/dist/shopify/legal/index.d.ts +19 -0
- package/dist/shopify/legal/index.js +8 -0
- package/dist/shopify/legal/shopify.d.ts +16 -0
- package/dist/shopify/legal/shopify.js +21 -0
- package/dist/shopify/migrations.d.ts +4 -0
- package/dist/shopify/migrations.js +67 -0
- package/dist/shopify/pages.d.ts +45 -0
- package/dist/shopify/pages.js +146 -0
- package/dist/shopify/ports.d.ts +77 -0
- package/dist/shopify/ports.js +1 -0
- package/dist/shopify/sales.d.ts +4 -0
- package/dist/shopify/sales.js +27 -0
- package/dist/shopify/scopes.d.ts +2 -0
- package/dist/shopify/scopes.js +13 -0
- package/dist/shopify/seal.d.ts +12 -0
- package/dist/shopify/seal.js +60 -0
- package/dist/shopify/shop.d.ts +4 -0
- package/dist/shopify/shop.js +15 -0
- package/dist/shopify/shops.d.ts +76 -0
- package/dist/shopify/shops.js +256 -0
- package/dist/shopify/sveltekit.d.ts +19 -0
- package/dist/shopify/sveltekit.js +18 -0
- package/dist/shopify/texts.d.ts +28 -0
- package/dist/shopify/texts.js +49 -0
- package/dist/shopify/toml.d.ts +22 -0
- package/dist/shopify/toml.js +125 -0
- package/dist/shopify/webhooks.d.ts +65 -0
- package/dist/shopify/webhooks.js +220 -0
- package/dist/stubs.d.ts +15 -0
- package/dist/stubs.js +287 -0
- package/dist/sveltekit.d.ts +20 -0
- package/dist/sveltekit.js +18 -0
- package/dist/testing/app.d.ts +36 -0
- package/dist/testing/app.js +155 -0
- package/dist/testing/browser.d.ts +21 -0
- package/dist/testing/browser.js +21 -0
- package/dist/testing/connection.d.ts +7 -0
- package/dist/testing/connection.js +303 -0
- package/dist/testing/database.d.ts +21 -0
- package/dist/testing/database.js +54 -0
- package/dist/testing/env-example.d.ts +4 -0
- package/dist/testing/env-example.js +33 -0
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/shopify-connection.d.ts +8 -0
- package/dist/testing/shopify-connection.js +313 -0
- package/dist/testing/shopify.d.ts +71 -0
- package/dist/testing/shopify.js +278 -0
- package/dist/testing/smtp.d.ts +38 -0
- package/dist/testing/smtp.js +165 -0
- package/dist/testing/stripe.d.ts +128 -0
- package/dist/testing/stripe.js +511 -0
- package/dist/texts.d.ts +139 -0
- package/dist/texts.js +265 -0
- package/dist/ui/AccountPage.svelte +143 -0
- package/dist/ui/AccountPage.svelte.d.ts +10 -0
- package/dist/ui/ConfirmEmailPage.svelte +21 -0
- package/dist/ui/ConfirmEmailPage.svelte.d.ts +10 -0
- package/dist/ui/ErrorPage.svelte +14 -0
- package/dist/ui/ErrorPage.svelte.d.ts +3 -0
- package/dist/ui/Field.svelte +12 -0
- package/dist/ui/Field.svelte.d.ts +10 -0
- package/dist/ui/ForgotPasswordPage.svelte +32 -0
- package/dist/ui/ForgotPasswordPage.svelte.d.ts +7 -0
- package/dist/ui/Form.svelte +30 -0
- package/dist/ui/Form.svelte.d.ts +11 -0
- package/dist/ui/Frame.svelte +84 -0
- package/dist/ui/Frame.svelte.d.ts +12 -0
- package/dist/ui/LegalPage.svelte +38 -0
- package/dist/ui/LegalPage.svelte.d.ts +9 -0
- package/dist/ui/Linked.svelte +5 -0
- package/dist/ui/Linked.svelte.d.ts +8 -0
- package/dist/ui/LoginPage.svelte +41 -0
- package/dist/ui/LoginPage.svelte.d.ts +10 -0
- package/dist/ui/LogoutPage.svelte +19 -0
- package/dist/ui/LogoutPage.svelte.d.ts +9 -0
- package/dist/ui/ResetPasswordPage.svelte +30 -0
- package/dist/ui/ResetPasswordPage.svelte.d.ts +10 -0
- package/dist/ui/SignupPage.svelte +52 -0
- package/dist/ui/SignupPage.svelte.d.ts +10 -0
- package/dist/ui/VerifyEmailPage.svelte +50 -0
- package/dist/ui/VerifyEmailPage.svelte.d.ts +10 -0
- package/dist/ui/base.css +287 -0
- package/dist/ui/data.d.ts +167 -0
- package/dist/ui/data.js +1 -0
- package/dist/ui/frame.d.ts +17 -0
- package/dist/ui/frame.js +27 -0
- package/dist/ui/index.d.ts +15 -0
- package/dist/ui/index.js +15 -0
- package/dist/variables.d.ts +20 -0
- package/dist/variables.js +62 -0
- package/package.json +88 -4
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The layer of the baukasten: its migrations, in the schema `baukasten`, as text in the code, so that
|
|
2
|
+
// they travel with it however a product bundles the package. Each is forward-only and never changes
|
|
3
|
+
// once released (baukasten-migrations.test.ts); the migration command lays `baukasten.migrations`,
|
|
4
|
+
// the list of these, before the first.
|
|
5
|
+
export const BAUKASTEN_MIGRATIONS = [
|
|
6
|
+
{
|
|
7
|
+
// The list of the migrations of the product: in the schema of the baukasten, so that the schema
|
|
8
|
+
// public belongs to the product alone.
|
|
9
|
+
name: '001_product_migrations.sql',
|
|
10
|
+
sql: `CREATE TABLE baukasten.product_migrations (
|
|
11
|
+
name text PRIMARY KEY,
|
|
12
|
+
checksum text NOT NULL,
|
|
13
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
14
|
+
);
|
|
15
|
+
`
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
// Secrets are kept as SHA-256, passwords as scrypt. A deleted account keeps its row without its
|
|
19
|
+
// address, for the numbers and the books; a product refers to an account by its id alone.
|
|
20
|
+
name: '002_accounts.sql',
|
|
21
|
+
sql: `CREATE TABLE baukasten.accounts (
|
|
22
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
23
|
+
email text UNIQUE CHECK (email = lower(email)),
|
|
24
|
+
password_hash text,
|
|
25
|
+
email_verified_at timestamptz,
|
|
26
|
+
-- The terms accepted at sign-up, named by their day, and when; accepting them includes the
|
|
27
|
+
-- statement to act for a business, which sign-up asks as well.
|
|
28
|
+
terms_version text NOT NULL,
|
|
29
|
+
terms_accepted_at timestamptz NOT NULL,
|
|
30
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
31
|
+
deleted_at timestamptz,
|
|
32
|
+
CHECK ((deleted_at IS NULL) = (email IS NOT NULL)),
|
|
33
|
+
CHECK (deleted_at IS NULL OR (password_hash IS NULL AND email_verified_at IS NULL))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE baukasten.sessions (
|
|
37
|
+
token_hash bytea PRIMARY KEY,
|
|
38
|
+
account_id uuid NOT NULL REFERENCES baukasten.accounts (id),
|
|
39
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
40
|
+
expires_at timestamptz NOT NULL
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX sessions_account ON baukasten.sessions (account_id);
|
|
43
|
+
|
|
44
|
+
-- One-time links sent by mail: confirm the address, set a new password, change the address.
|
|
45
|
+
CREATE TABLE baukasten.links (
|
|
46
|
+
token_hash bytea PRIMARY KEY,
|
|
47
|
+
account_id uuid NOT NULL REFERENCES baukasten.accounts (id),
|
|
48
|
+
purpose text NOT NULL CHECK (purpose IN ('verify', 'reset', 'change_email')),
|
|
49
|
+
new_email text CHECK (new_email = lower(new_email)),
|
|
50
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
51
|
+
expires_at timestamptz NOT NULL,
|
|
52
|
+
used_at timestamptz,
|
|
53
|
+
CHECK ((purpose = 'change_email') = (new_email IS NOT NULL))
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX links_account ON baukasten.links (account_id);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE baukasten.api_keys (
|
|
58
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
59
|
+
account_id uuid NOT NULL REFERENCES baukasten.accounts (id),
|
|
60
|
+
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 80),
|
|
61
|
+
key_hash bytea NOT NULL UNIQUE,
|
|
62
|
+
-- The start of the key, to tell keys apart in a list; far too short to use.
|
|
63
|
+
prefix text NOT NULL,
|
|
64
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
65
|
+
revoked_at timestamptz
|
|
66
|
+
);
|
|
67
|
+
CREATE INDEX api_keys_account ON baukasten.api_keys (account_id);
|
|
68
|
+
`
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
// Every new column has a default, so that the version before signs up accounts as it did.
|
|
72
|
+
name: '003_stripe_and_sales.sql',
|
|
73
|
+
sql: `-- The Kennung of the link an account came by (ohne without one); the mode of Stripe it was made
|
|
74
|
+
-- in, live once it gets a customer with the live key; and its customer at Stripe.
|
|
75
|
+
ALTER TABLE baukasten.accounts
|
|
76
|
+
ADD COLUMN via text NOT NULL DEFAULT 'ohne'
|
|
77
|
+
CHECK (length(via) <= 32 AND via ~ '^[a-z0-9]+(-[a-z0-9]+)*$'),
|
|
78
|
+
ADD COLUMN stripe_mode text NOT NULL DEFAULT 'test' CHECK (stripe_mode IN ('test', 'live')),
|
|
79
|
+
ADD COLUMN stripe_customer_id text UNIQUE;
|
|
80
|
+
|
|
81
|
+
-- Every event of the webhook that was applied, so that each applies once.
|
|
82
|
+
CREATE TABLE baukasten.stripe_events (
|
|
83
|
+
id text PRIMARY KEY,
|
|
84
|
+
type text NOT NULL,
|
|
85
|
+
received_at timestamptz NOT NULL DEFAULT now()
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
-- The subscriptions of the accounts as Stripe last said: each read back under the lock of its
|
|
89
|
+
-- account. The lookup key is gone once a newer price took it; the subscription keeps its price.
|
|
90
|
+
CREATE TABLE baukasten.subscriptions (
|
|
91
|
+
id text PRIMARY KEY,
|
|
92
|
+
account_id uuid NOT NULL REFERENCES baukasten.accounts (id),
|
|
93
|
+
status text NOT NULL,
|
|
94
|
+
product text NOT NULL,
|
|
95
|
+
price text NOT NULL,
|
|
96
|
+
lookup_key text,
|
|
97
|
+
quantity integer NOT NULL CHECK (quantity >= 0),
|
|
98
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX subscriptions_account ON baukasten.subscriptions (account_id);
|
|
101
|
+
|
|
102
|
+
-- The revenue: paid invoices of the subscriptions and paid purchases (a purchase makes no invoice),
|
|
103
|
+
-- in the smallest unit of their currency, as Stripe writes it.
|
|
104
|
+
CREATE TABLE baukasten.invoices (
|
|
105
|
+
id text PRIMARY KEY,
|
|
106
|
+
account_id uuid NOT NULL REFERENCES baukasten.accounts (id),
|
|
107
|
+
amount bigint NOT NULL CHECK (amount >= 0),
|
|
108
|
+
currency text NOT NULL CHECK (currency ~ '^[a-z]{3}$'),
|
|
109
|
+
paid_at timestamptz NOT NULL
|
|
110
|
+
);
|
|
111
|
+
CREATE INDEX invoices_account ON baukasten.invoices (account_id);
|
|
112
|
+
|
|
113
|
+
-- A purchase is named by its Checkout session, with the product and the lookup key of its price.
|
|
114
|
+
CREATE TABLE baukasten.purchases (
|
|
115
|
+
id text PRIMARY KEY,
|
|
116
|
+
account_id uuid NOT NULL REFERENCES baukasten.accounts (id),
|
|
117
|
+
product text NOT NULL,
|
|
118
|
+
price text NOT NULL,
|
|
119
|
+
amount bigint NOT NULL CHECK (amount >= 0),
|
|
120
|
+
currency text NOT NULL CHECK (currency ~ '^[a-z]{3}$'),
|
|
121
|
+
paid_at timestamptz NOT NULL
|
|
122
|
+
);
|
|
123
|
+
CREATE INDEX purchases_account ON baukasten.purchases (account_id);
|
|
124
|
+
`
|
|
125
|
+
}
|
|
126
|
+
];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Catalog } from '../product.js';
|
|
2
|
+
import type { Account } from './accounts.js';
|
|
3
|
+
import { type Shop } from './catalog.js';
|
|
4
|
+
import type { StripeMode } from './config.js';
|
|
5
|
+
import type { Log } from './log.js';
|
|
6
|
+
import type { Db, Queryable, StripeApi, SubscriptionStatus } from './ports.js';
|
|
7
|
+
/** A failure Stripe reported, named by its kind, like `authentication_error`; never with the key. */
|
|
8
|
+
export declare class StripeFailed extends Error {
|
|
9
|
+
name: string;
|
|
10
|
+
readonly kind: string;
|
|
11
|
+
constructor(kind: string, message: string, options?: ErrorOptions);
|
|
12
|
+
}
|
|
13
|
+
/** A subscription as the product gets it: its product and the lookup key of its price. */
|
|
14
|
+
export interface Subscription {
|
|
15
|
+
id: string;
|
|
16
|
+
status: SubscriptionStatus;
|
|
17
|
+
product: string;
|
|
18
|
+
/** Null when the price gave its lookup key to a newer one; the subscription keeps its price. */
|
|
19
|
+
price: string | null;
|
|
20
|
+
quantity: number;
|
|
21
|
+
}
|
|
22
|
+
/** The subscription of an account as Stripe has it now. */
|
|
23
|
+
export interface SubscriptionChange {
|
|
24
|
+
accountId: string;
|
|
25
|
+
subscription: Subscription;
|
|
26
|
+
}
|
|
27
|
+
/** A purchase of a price of the catalog, paid; its id is that of its Checkout. */
|
|
28
|
+
export interface Purchase {
|
|
29
|
+
id: string;
|
|
30
|
+
accountId: string;
|
|
31
|
+
product: string;
|
|
32
|
+
price: string;
|
|
33
|
+
/** In the smallest unit of the currency. */
|
|
34
|
+
amount: number;
|
|
35
|
+
/** Like CHF. */
|
|
36
|
+
currency: string;
|
|
37
|
+
paidAt: Date;
|
|
38
|
+
}
|
|
39
|
+
/** What the baukasten calls in the product when Stripe reports a change, in the same transaction. */
|
|
40
|
+
export interface BillingHooks {
|
|
41
|
+
subscriptionChanged(tx: Queryable, change: SubscriptionChange): Promise<void>;
|
|
42
|
+
purchasePaid(tx: Queryable, purchase: Purchase): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
/** A price of the catalog by its lookup key, with a quantity where the product counts one. */
|
|
45
|
+
export interface Order {
|
|
46
|
+
price: string;
|
|
47
|
+
/** For a subscription whose product counts a quantity; 1 without. */
|
|
48
|
+
quantity?: number;
|
|
49
|
+
/** Paths of the product: back from Checkout after paying, and after leaving it. */
|
|
50
|
+
success: string;
|
|
51
|
+
cancel: string;
|
|
52
|
+
}
|
|
53
|
+
/** A Checkout page, or none, because the account already has a running subscription. */
|
|
54
|
+
export type CheckoutPage = {
|
|
55
|
+
kind: 'open';
|
|
56
|
+
url: string;
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'subscribed';
|
|
59
|
+
};
|
|
60
|
+
export type Received = {
|
|
61
|
+
kind: 'invalid';
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'applied';
|
|
64
|
+
type: string;
|
|
65
|
+
} | {
|
|
66
|
+
kind: 'repeated';
|
|
67
|
+
type: string;
|
|
68
|
+
};
|
|
69
|
+
export interface Billing {
|
|
70
|
+
checkout(account: Account, order: Order): Promise<CheckoutPage>;
|
|
71
|
+
/** The customer portal of the account, which leads back to the path `back`. */
|
|
72
|
+
portal(account: Account, back: string): Promise<string>;
|
|
73
|
+
hasCustomer(accountId: string): Promise<boolean>;
|
|
74
|
+
/** Verifies one request of the webhook and applies its event once. */
|
|
75
|
+
receive(payload: string, signature: string | null): Promise<Received>;
|
|
76
|
+
/** Ends every subscription of the account at once, as when it is deleted. */
|
|
77
|
+
endSubscriptions(accountId: string): Promise<void>;
|
|
78
|
+
/** Receipts and invoices follow a new login address. */
|
|
79
|
+
followEmail(accountId: string, email: string): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
/** Billing without Stripe, locally: an account that is no customer has nothing to end or follow. */
|
|
82
|
+
export declare function noBilling(db: Queryable): Billing;
|
|
83
|
+
export declare function createBilling(options: {
|
|
84
|
+
db: Db;
|
|
85
|
+
stripe: StripeApi;
|
|
86
|
+
catalog: Catalog;
|
|
87
|
+
shop: Shop;
|
|
88
|
+
mode: StripeMode;
|
|
89
|
+
hooks: BillingHooks;
|
|
90
|
+
log: Log;
|
|
91
|
+
now: () => Date;
|
|
92
|
+
/** The sentence by the box for the terms in Checkout, in Markdown, with their link. */
|
|
93
|
+
termsMessage: (termsUrl: string) => string;
|
|
94
|
+
}): Billing;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { NO_DEFAULT_PORTAL, priceFits, pricesOf } from './catalog.js';
|
|
2
|
+
/** A failure Stripe reported, named by its kind, like `authentication_error`; never with the key. */
|
|
3
|
+
export class StripeFailed extends Error {
|
|
4
|
+
name = 'StripeFailed';
|
|
5
|
+
kind;
|
|
6
|
+
constructor(kind, message, options) {
|
|
7
|
+
super(message, options);
|
|
8
|
+
this.kind = kind;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const ENDED = ['canceled', 'incomplete_expired'];
|
|
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. */
|
|
14
|
+
const CHECKOUT_OPEN_MS = 31 * 60_000;
|
|
15
|
+
const OWN_PATH = /^\/(?!\/)[^\s\\]*$/;
|
|
16
|
+
function ownPath(path, what) {
|
|
17
|
+
if (!OWN_PATH.test(path))
|
|
18
|
+
throw new Error(`${what} must be a path of the product: ${path}`);
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
const NOT_CONFIGURED = 'Stripe is not configured: STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are missing';
|
|
22
|
+
/** The customer of the account at Stripe and the mode of the account, which is that of the customer. */
|
|
23
|
+
async function customerOf(db, accountId) {
|
|
24
|
+
const [row] = await db.query('SELECT stripe_customer_id, stripe_mode FROM baukasten.accounts WHERE id = $1', [accountId]);
|
|
25
|
+
if (row === undefined)
|
|
26
|
+
throw new Error('no such account');
|
|
27
|
+
return { id: row.stripe_customer_id, mode: row.stripe_mode };
|
|
28
|
+
}
|
|
29
|
+
/** Billing without Stripe, locally: an account that is no customer has nothing to end or follow. */
|
|
30
|
+
export function noBilling(db) {
|
|
31
|
+
const without = async (accountId) => {
|
|
32
|
+
if ((await customerOf(db, accountId)).id !== null) {
|
|
33
|
+
throw new Error(`the account is a customer at Stripe, but ${NOT_CONFIGURED}`);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
checkout: () => Promise.reject(new Error(NOT_CONFIGURED)),
|
|
38
|
+
portal: () => Promise.reject(new Error(NOT_CONFIGURED)),
|
|
39
|
+
hasCustomer: async (accountId) => (await customerOf(db, accountId)).id !== null,
|
|
40
|
+
receive: () => Promise.reject(new Error(NOT_CONFIGURED)),
|
|
41
|
+
endSubscriptions: without,
|
|
42
|
+
followEmail: without
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function createBilling(options) {
|
|
46
|
+
const { db, stripe, catalog, shop, mode, hooks, log, now } = options;
|
|
47
|
+
const currency = catalog.currency.toLowerCase();
|
|
48
|
+
const prices = new Map(pricesOf(catalog).map((price) => [price.lookupKey, price]));
|
|
49
|
+
const products = new Map(catalog.products.map((product) => [product.id, product]));
|
|
50
|
+
const priceIds = new Map();
|
|
51
|
+
let portalId = null;
|
|
52
|
+
/** The price at Stripe for the catalog's, found by its lookup key once per process. */
|
|
53
|
+
async function priceId(wanted) {
|
|
54
|
+
const known = priceIds.get(wanted.lookupKey);
|
|
55
|
+
if (known !== undefined)
|
|
56
|
+
return known;
|
|
57
|
+
const found = await stripe.activePrice(wanted.lookupKey);
|
|
58
|
+
// The pages promise the price of the catalog; a different one at Stripe would charge another.
|
|
59
|
+
if (found === null || !priceFits(found, wanted)) {
|
|
60
|
+
throw new Error(`the price ${wanted.lookupKey} at Stripe is not the one of the catalog: migrate first`);
|
|
61
|
+
}
|
|
62
|
+
priceIds.set(wanted.lookupKey, found.id);
|
|
63
|
+
return found.id;
|
|
64
|
+
}
|
|
65
|
+
async function portalConfiguration() {
|
|
66
|
+
if (portalId !== null)
|
|
67
|
+
return portalId;
|
|
68
|
+
const portal = await stripe.defaultPortal();
|
|
69
|
+
if (portal === null)
|
|
70
|
+
throw new Error(NO_DEFAULT_PORTAL);
|
|
71
|
+
portalId = portal.id;
|
|
72
|
+
return portalId;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The customer of the account, made at the first need. A request at the same time may have made
|
|
76
|
+
* one first: the stored one wins. An account that gets its customer with the live key is live from
|
|
77
|
+
* then on; one from the test mode has customers of the test mode only.
|
|
78
|
+
*/
|
|
79
|
+
async function customer(account) {
|
|
80
|
+
// 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;
|
|
82
|
+
if (known !== null)
|
|
83
|
+
return known;
|
|
84
|
+
const created = await stripe.createCustomer({ email: account.email, accountId: account.id });
|
|
85
|
+
const [row] = await db.query(`UPDATE baukasten.accounts SET
|
|
86
|
+
stripe_mode = CASE WHEN stripe_customer_id IS NULL AND $3 THEN 'live' ELSE stripe_mode END,
|
|
87
|
+
stripe_customer_id = coalesce(stripe_customer_id, $2)
|
|
88
|
+
WHERE id = $1 AND deleted_at IS NULL
|
|
89
|
+
RETURNING stripe_customer_id`, [account.id, created, mode === 'live']);
|
|
90
|
+
if (row === undefined)
|
|
91
|
+
throw new Error('the account was deleted');
|
|
92
|
+
return row.stripe_customer_id;
|
|
93
|
+
}
|
|
94
|
+
function productOf(price) {
|
|
95
|
+
const product = products.get(price.product);
|
|
96
|
+
if (product === undefined)
|
|
97
|
+
throw new Error(`a price without its product: ${price.lookupKey}`);
|
|
98
|
+
return product;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The customer of the account, unless it is one of the test mode under the live key: that one the
|
|
102
|
+
* key does not reach, and its subscriptions move no money. A live customer under the test key, as
|
|
103
|
+
* after a switch to live taken back, is asked for and fails loudly: its subscriptions move money.
|
|
104
|
+
*/
|
|
105
|
+
async function reachable(accountId) {
|
|
106
|
+
const customer = await customerOf(db, accountId);
|
|
107
|
+
if (customer.id === null)
|
|
108
|
+
return { kind: 'none' };
|
|
109
|
+
if (customer.mode === 'test' && mode === 'live')
|
|
110
|
+
return { kind: 'test mode' };
|
|
111
|
+
return { kind: 'customer', id: customer.id };
|
|
112
|
+
}
|
|
113
|
+
/** 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);
|
|
116
|
+
switch (customer.kind) {
|
|
117
|
+
case 'customer':
|
|
118
|
+
return customer.id;
|
|
119
|
+
case 'none':
|
|
120
|
+
return null;
|
|
121
|
+
case 'test mode':
|
|
122
|
+
log.info('stripe: a customer of the test mode, left alone', { account: accountId });
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The account of a customer, locked until the transaction ends; none for another's customer. */
|
|
127
|
+
async function accountOf(tx, customerId, event) {
|
|
128
|
+
const [account] = await tx.query(`SELECT id, deleted_at IS NOT NULL AS deleted FROM baukasten.accounts
|
|
129
|
+
WHERE stripe_customer_id = $1 FOR UPDATE`, [customerId]);
|
|
130
|
+
if (account !== undefined)
|
|
131
|
+
return account;
|
|
132
|
+
log.warn('stripe: an event of a customer without an account', {
|
|
133
|
+
type: event.type,
|
|
134
|
+
event: event.id,
|
|
135
|
+
customer: customerId
|
|
136
|
+
});
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
async function syncSubscription(tx, event, customerId, subscriptionId) {
|
|
140
|
+
const account = await accountOf(tx, customerId, event);
|
|
141
|
+
if (account === null)
|
|
142
|
+
return;
|
|
143
|
+
const read = await stripe.subscription(subscriptionId);
|
|
144
|
+
// A Checkout opened before the deletion and paid after it: a deleted account pays for nothing.
|
|
145
|
+
// Its end comes back as an event of its own and stays.
|
|
146
|
+
if (account.deleted && running(read.status)) {
|
|
147
|
+
await stripe.cancelSubscription(read.id);
|
|
148
|
+
log.warn('stripe: a running subscription of a deleted account, ended; the operator refunds its paid invoice', { account: account.id, subscription: read.id, invoice: read.latestInvoice });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await tx.query(`INSERT INTO baukasten.subscriptions (id, account_id, status, product, price, lookup_key, quantity)
|
|
152
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
153
|
+
ON CONFLICT (id) DO UPDATE SET status = excluded.status, product = excluded.product,
|
|
154
|
+
price = excluded.price, lookup_key = excluded.lookup_key, quantity = excluded.quantity,
|
|
155
|
+
updated_at = now()`, [
|
|
156
|
+
read.id,
|
|
157
|
+
account.id,
|
|
158
|
+
read.status,
|
|
159
|
+
read.product,
|
|
160
|
+
read.price.id,
|
|
161
|
+
read.price.lookupKey,
|
|
162
|
+
read.quantity
|
|
163
|
+
]);
|
|
164
|
+
// The product deleted its data of a deleted account; there is nothing left to entitle.
|
|
165
|
+
if (account.deleted)
|
|
166
|
+
return;
|
|
167
|
+
await hooks.subscriptionChanged(tx, {
|
|
168
|
+
accountId: account.id,
|
|
169
|
+
subscription: {
|
|
170
|
+
id: read.id,
|
|
171
|
+
status: read.status,
|
|
172
|
+
product: read.product,
|
|
173
|
+
price: read.price.lookupKey,
|
|
174
|
+
quantity: read.quantity
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function otherCurrency(event, paid) {
|
|
179
|
+
if (paid === currency)
|
|
180
|
+
return;
|
|
181
|
+
log.warn('stripe: paid in another currency than that of the catalog, not counted', {
|
|
182
|
+
type: event.type,
|
|
183
|
+
event: event.id,
|
|
184
|
+
currency: paid
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
async function purchasePaid(tx, event, session) {
|
|
188
|
+
if (!session.paid)
|
|
189
|
+
return;
|
|
190
|
+
const account = await accountOf(tx, session.customerId, event);
|
|
191
|
+
if (account === null)
|
|
192
|
+
return;
|
|
193
|
+
otherCurrency(event, session.currency);
|
|
194
|
+
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`, [
|
|
196
|
+
session.id,
|
|
197
|
+
account.id,
|
|
198
|
+
session.product,
|
|
199
|
+
session.lookupKey,
|
|
200
|
+
session.amount,
|
|
201
|
+
session.currency,
|
|
202
|
+
event.created
|
|
203
|
+
]);
|
|
204
|
+
// Completed and paid later (a payment that takes days) are two events of one purchase.
|
|
205
|
+
if (inserted.length === 0 || account.deleted)
|
|
206
|
+
return;
|
|
207
|
+
await hooks.purchasePaid(tx, {
|
|
208
|
+
id: session.id,
|
|
209
|
+
accountId: account.id,
|
|
210
|
+
product: session.product,
|
|
211
|
+
price: session.lookupKey,
|
|
212
|
+
amount: session.amount,
|
|
213
|
+
currency: session.currency.toUpperCase(),
|
|
214
|
+
paidAt: event.created
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
async function checkoutCompleted(tx, event, session) {
|
|
218
|
+
switch (session.mode) {
|
|
219
|
+
case 'subscription':
|
|
220
|
+
return syncSubscription(tx, event, session.customerId, session.subscriptionId);
|
|
221
|
+
case 'payment':
|
|
222
|
+
return purchasePaid(tx, event, session);
|
|
223
|
+
case 'other':
|
|
224
|
+
log.info('stripe: a Checkout the baukasten did not open, left alone', {
|
|
225
|
+
event: event.id,
|
|
226
|
+
session: session.id
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function apply(tx, event) {
|
|
232
|
+
switch (event.kind) {
|
|
233
|
+
case 'checkout':
|
|
234
|
+
return checkoutCompleted(tx, event, event.session);
|
|
235
|
+
case 'subscription':
|
|
236
|
+
return syncSubscription(tx, event, event.customerId, event.subscriptionId);
|
|
237
|
+
case 'invoice': {
|
|
238
|
+
const { invoice } = event;
|
|
239
|
+
const account = await accountOf(tx, invoice.customerId, event);
|
|
240
|
+
if (account === null)
|
|
241
|
+
return;
|
|
242
|
+
otherCurrency(event, invoice.currency);
|
|
243
|
+
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]);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case 'other':
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
async checkout(account, order) {
|
|
253
|
+
const wanted = prices.get(order.price);
|
|
254
|
+
if (wanted === undefined)
|
|
255
|
+
throw new Error(`the catalog has no price ${order.price}`);
|
|
256
|
+
const product = productOf(wanted);
|
|
257
|
+
const base = {
|
|
258
|
+
accountId: account.id,
|
|
259
|
+
successUrl: `${shop.origin}${ownPath(order.success, 'success')}`,
|
|
260
|
+
cancelUrl: `${shop.origin}${ownPath(order.cancel, 'cancel')}`,
|
|
261
|
+
termsMessage: options.termsMessage(`${shop.origin}/terms`),
|
|
262
|
+
expiresAt: new Date(now().getTime() + CHECKOUT_OPEN_MS)
|
|
263
|
+
};
|
|
264
|
+
switch (product.kind) {
|
|
265
|
+
case 'subscription': {
|
|
266
|
+
const max = product.quantity === undefined ? 1 : product.quantity.max;
|
|
267
|
+
const quantity = order.quantity ?? 1;
|
|
268
|
+
if (!Number.isInteger(quantity) || quantity < 1 || quantity > max) {
|
|
269
|
+
throw new Error(`the quantity of ${order.price} is 1 to ${max}, not ${quantity}`);
|
|
270
|
+
}
|
|
271
|
+
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({
|
|
279
|
+
...base,
|
|
280
|
+
customerId,
|
|
281
|
+
priceId: stripePrice,
|
|
282
|
+
mode: 'subscription',
|
|
283
|
+
quantity,
|
|
284
|
+
adjustable: product.quantity === undefined
|
|
285
|
+
? { kind: 'fixed' }
|
|
286
|
+
: { kind: 'up to', max: product.quantity.max }
|
|
287
|
+
});
|
|
288
|
+
return { kind: 'open', url };
|
|
289
|
+
}
|
|
290
|
+
case 'purchase': {
|
|
291
|
+
if (order.quantity !== undefined && order.quantity !== 1) {
|
|
292
|
+
throw new Error(`a purchase of ${order.price} is one, not ${order.quantity}`);
|
|
293
|
+
}
|
|
294
|
+
const stripePrice = await priceId(wanted);
|
|
295
|
+
const url = await stripe.createCheckout({
|
|
296
|
+
...base,
|
|
297
|
+
customerId: await customer(account),
|
|
298
|
+
priceId: stripePrice,
|
|
299
|
+
mode: 'payment',
|
|
300
|
+
product: product.id,
|
|
301
|
+
lookupKey: wanted.lookupKey
|
|
302
|
+
});
|
|
303
|
+
return { kind: 'open', url };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
async portal(account, back) {
|
|
308
|
+
const configurationId = await portalConfiguration();
|
|
309
|
+
return stripe.createPortalSession({
|
|
310
|
+
customerId: await customer(account),
|
|
311
|
+
configurationId,
|
|
312
|
+
returnUrl: `${shop.origin}${ownPath(back, 'back')}`
|
|
313
|
+
});
|
|
314
|
+
},
|
|
315
|
+
hasCustomer: async (accountId) => (await reachable(accountId)).kind === 'customer',
|
|
316
|
+
async receive(payload, signature) {
|
|
317
|
+
const read = stripe.event(payload, signature);
|
|
318
|
+
if (read.kind === 'invalid')
|
|
319
|
+
return { kind: 'invalid' };
|
|
320
|
+
const { event } = read;
|
|
321
|
+
const applied = await db.transaction(async (tx) => {
|
|
322
|
+
const fresh = await tx.query(`INSERT INTO baukasten.stripe_events (id, type) VALUES ($1, $2)
|
|
323
|
+
ON CONFLICT (id) DO NOTHING RETURNING id`, [event.id, event.type]);
|
|
324
|
+
if (fresh.length === 0)
|
|
325
|
+
return false;
|
|
326
|
+
await apply(tx, event);
|
|
327
|
+
return true;
|
|
328
|
+
});
|
|
329
|
+
log.info('stripe event', { type: event.type, event: event.id, applied });
|
|
330
|
+
return applied
|
|
331
|
+
? { kind: 'applied', type: event.type }
|
|
332
|
+
: { kind: 'repeated', type: event.type };
|
|
333
|
+
},
|
|
334
|
+
async endSubscriptions(accountId) {
|
|
335
|
+
const customerId = await actOn(accountId);
|
|
336
|
+
if (customerId === null)
|
|
337
|
+
return;
|
|
338
|
+
for (const subscription of await stripe.subscriptionsOf(customerId)) {
|
|
339
|
+
if (running(subscription.status))
|
|
340
|
+
await stripe.cancelSubscription(subscription.id);
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
async followEmail(accountId, email) {
|
|
344
|
+
const customerId = await actOn(accountId);
|
|
345
|
+
if (customerId !== null)
|
|
346
|
+
await stripe.updateCustomerEmail(customerId, email);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Catalog } from '../product.js';
|
|
2
|
+
import type { NewPrice, PortalConfiguration, StripeApi, StripePrice } from './ports.js';
|
|
3
|
+
/** The name and the address of the product, which the customer portal shows. */
|
|
4
|
+
export interface Shop {
|
|
5
|
+
name: string;
|
|
6
|
+
origin: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CatalogStep {
|
|
9
|
+
subject: 'product' | 'price' | 'portal';
|
|
10
|
+
id: string;
|
|
11
|
+
action: 'created' | 'updated' | 'exists';
|
|
12
|
+
}
|
|
13
|
+
export declare function pricesOf(catalog: Catalog): NewPrice[];
|
|
14
|
+
/** Whether the price at Stripe charges what the catalog says. */
|
|
15
|
+
export declare function priceFits(found: StripePrice, wanted: NewPrice): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* 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.
|
|
19
|
+
*/
|
|
20
|
+
export declare function portalConfiguration(catalog: Catalog, shop: Shop, priceId: (lookupKey: string) => string): PortalConfiguration;
|
|
21
|
+
/**
|
|
22
|
+
* Whether `found` holds every value of `wanted`. Lists hold the same items in any order: Stripe
|
|
23
|
+
* need not keep the order it was given.
|
|
24
|
+
*/
|
|
25
|
+
export declare function contains(found: unknown, wanted: unknown): boolean;
|
|
26
|
+
/** Only the Dashboard creates the default configuration of the customer portal. */
|
|
27
|
+
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[]>;
|
|
29
|
+
/** 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[]>;
|
|
31
|
+
/**
|
|
32
|
+
* The enabled webhook endpoints of the Stripe account that are not this product's: an account copied
|
|
33
|
+
* from another product brings that one's along, and they would get the events of this one. Named by
|
|
34
|
+
* id and host: the path of an endpoint often carries a secret.
|
|
35
|
+
*/
|
|
36
|
+
export declare function foreignWebhooks(stripe: StripeApi, origin: string): Promise<{
|
|
37
|
+
id: string;
|
|
38
|
+
host: string;
|
|
39
|
+
}[]>;
|