@tollwyn/baukasten 0.0.2 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/README.md +254 -1
  2. package/dist/bin/check.d.ts +2 -0
  3. package/dist/bin/check.js +38 -0
  4. package/dist/bin/migrate.d.ts +2 -0
  5. package/dist/bin/migrate.js +111 -0
  6. package/dist/bin/stubs.d.ts +2 -0
  7. package/dist/bin/stubs.js +38 -0
  8. package/dist/bin/test-accounts.d.ts +2 -0
  9. package/dist/bin/test-accounts.js +62 -0
  10. package/dist/check.d.ts +20 -0
  11. package/dist/check.js +285 -0
  12. package/dist/index.d.ts +7 -0
  13. package/dist/index.js +9 -0
  14. package/dist/product.d.ts +161 -0
  15. package/dist/product.js +152 -0
  16. package/dist/server/accounts.d.ts +128 -0
  17. package/dist/server/accounts.js +282 -0
  18. package/dist/server/adapter/db.d.ts +5 -0
  19. package/dist/server/adapter/db.js +63 -0
  20. package/dist/server/adapter/log.d.ts +3 -0
  21. package/dist/server/adapter/log.js +12 -0
  22. package/dist/server/adapter/mailer.d.ts +4 -0
  23. package/dist/server/adapter/mailer.js +56 -0
  24. package/dist/server/adapter/migrate.d.ts +21 -0
  25. package/dist/server/adapter/migrate.js +113 -0
  26. package/dist/server/adapter/secrets.d.ts +2 -0
  27. package/dist/server/adapter/secrets.js +40 -0
  28. package/dist/server/adapter/stripe.d.ts +11 -0
  29. package/dist/server/adapter/stripe.js +333 -0
  30. package/dist/server/api.d.ts +25 -0
  31. package/dist/server/api.js +98 -0
  32. package/dist/server/baukasten-migrations.d.ts +4 -0
  33. package/dist/server/baukasten-migrations.js +126 -0
  34. package/dist/server/billing.d.ts +94 -0
  35. package/dist/server/billing.js +349 -0
  36. package/dist/server/catalog.d.ts +39 -0
  37. package/dist/server/catalog.js +193 -0
  38. package/dist/server/config.d.ts +108 -0
  39. package/dist/server/config.js +244 -0
  40. package/dist/server/endpoints.d.ts +44 -0
  41. package/dist/server/endpoints.js +159 -0
  42. package/dist/server/fields.d.ts +7 -0
  43. package/dist/server/fields.js +18 -0
  44. package/dist/server/headers.d.ts +1 -0
  45. package/dist/server/headers.js +9 -0
  46. package/dist/server/health.d.ts +34 -0
  47. package/dist/server/health.js +116 -0
  48. package/dist/server/hook.d.ts +92 -0
  49. package/dist/server/hook.js +293 -0
  50. package/dist/server/index.d.ts +18 -0
  51. package/dist/server/index.js +11 -0
  52. package/dist/server/legal/de.d.ts +2 -0
  53. package/dist/server/legal/de.js +251 -0
  54. package/dist/server/legal/document.d.ts +37 -0
  55. package/dist/server/legal/document.js +30 -0
  56. package/dist/server/legal/en.d.ts +2 -0
  57. package/dist/server/legal/en.js +251 -0
  58. package/dist/server/legal/fleet.d.ts +114 -0
  59. package/dist/server/legal/fleet.js +89 -0
  60. package/dist/server/legal/index.d.ts +3 -0
  61. package/dist/server/legal/index.js +11 -0
  62. package/dist/server/limits.d.ts +58 -0
  63. package/dist/server/limits.js +76 -0
  64. package/dist/server/log.d.ts +26 -0
  65. package/dist/server/log.js +61 -0
  66. package/dist/server/machine.d.ts +4 -0
  67. package/dist/server/machine.js +57 -0
  68. package/dist/server/mail.d.ts +20 -0
  69. package/dist/server/mail.js +29 -0
  70. package/dist/server/messages.d.ts +105 -0
  71. package/dist/server/messages.js +232 -0
  72. package/dist/server/migrations.d.ts +28 -0
  73. package/dist/server/migrations.js +59 -0
  74. package/dist/server/openapi.d.ts +24 -0
  75. package/dist/server/openapi.js +173 -0
  76. package/dist/server/pages.d.ts +158 -0
  77. package/dist/server/pages.js +491 -0
  78. package/dist/server/ports.d.ts +273 -0
  79. package/dist/server/ports.js +1 -0
  80. package/dist/server/runs.d.ts +30 -0
  81. package/dist/server/runs.js +55 -0
  82. package/dist/server/sales.d.ts +38 -0
  83. package/dist/server/sales.js +132 -0
  84. package/dist/server/services.d.ts +57 -0
  85. package/dist/server/services.js +44 -0
  86. package/dist/server/start.d.ts +14 -0
  87. package/dist/server/start.js +65 -0
  88. package/dist/server/stripe.d.ts +33 -0
  89. package/dist/server/stripe.js +29 -0
  90. package/dist/server/terms.d.ts +7 -0
  91. package/dist/server/terms.js +9 -0
  92. package/dist/server/test-accounts.d.ts +28 -0
  93. package/dist/server/test-accounts.js +62 -0
  94. package/dist/server/via.d.ts +9 -0
  95. package/dist/server/via.js +15 -0
  96. package/dist/server/web.d.ts +48 -0
  97. package/dist/server/web.js +105 -0
  98. package/dist/shopify/adapter/shopify.d.ts +8 -0
  99. package/dist/shopify/adapter/shopify.js +232 -0
  100. package/dist/shopify/admin.d.ts +33 -0
  101. package/dist/shopify/admin.js +47 -0
  102. package/dist/shopify/app.d.ts +60 -0
  103. package/dist/shopify/app.js +67 -0
  104. package/dist/shopify/config.d.ts +53 -0
  105. package/dist/shopify/config.js +133 -0
  106. package/dist/shopify/constants.d.ts +27 -0
  107. package/dist/shopify/constants.js +27 -0
  108. package/dist/shopify/headers.d.ts +1 -0
  109. package/dist/shopify/headers.js +8 -0
  110. package/dist/shopify/hmac.d.ts +1 -0
  111. package/dist/shopify/hmac.js +9 -0
  112. package/dist/shopify/hook.d.ts +60 -0
  113. package/dist/shopify/hook.js +508 -0
  114. package/dist/shopify/id-token.d.ts +12 -0
  115. package/dist/shopify/id-token.js +66 -0
  116. package/dist/shopify/index.d.ts +19 -0
  117. package/dist/shopify/index.js +15 -0
  118. package/dist/shopify/legal/de.d.ts +3 -0
  119. package/dist/shopify/legal/de.js +123 -0
  120. package/dist/shopify/legal/en.d.ts +3 -0
  121. package/dist/shopify/legal/en.js +121 -0
  122. package/dist/shopify/legal/index.d.ts +19 -0
  123. package/dist/shopify/legal/index.js +8 -0
  124. package/dist/shopify/legal/shopify.d.ts +16 -0
  125. package/dist/shopify/legal/shopify.js +21 -0
  126. package/dist/shopify/migrations.d.ts +4 -0
  127. package/dist/shopify/migrations.js +67 -0
  128. package/dist/shopify/pages.d.ts +45 -0
  129. package/dist/shopify/pages.js +146 -0
  130. package/dist/shopify/ports.d.ts +77 -0
  131. package/dist/shopify/ports.js +1 -0
  132. package/dist/shopify/sales.d.ts +4 -0
  133. package/dist/shopify/sales.js +27 -0
  134. package/dist/shopify/scopes.d.ts +2 -0
  135. package/dist/shopify/scopes.js +13 -0
  136. package/dist/shopify/seal.d.ts +12 -0
  137. package/dist/shopify/seal.js +60 -0
  138. package/dist/shopify/shop.d.ts +4 -0
  139. package/dist/shopify/shop.js +15 -0
  140. package/dist/shopify/shops.d.ts +76 -0
  141. package/dist/shopify/shops.js +256 -0
  142. package/dist/shopify/sveltekit.d.ts +19 -0
  143. package/dist/shopify/sveltekit.js +18 -0
  144. package/dist/shopify/texts.d.ts +28 -0
  145. package/dist/shopify/texts.js +49 -0
  146. package/dist/shopify/toml.d.ts +22 -0
  147. package/dist/shopify/toml.js +125 -0
  148. package/dist/shopify/webhooks.d.ts +65 -0
  149. package/dist/shopify/webhooks.js +220 -0
  150. package/dist/stubs.d.ts +15 -0
  151. package/dist/stubs.js +287 -0
  152. package/dist/sveltekit.d.ts +20 -0
  153. package/dist/sveltekit.js +18 -0
  154. package/dist/testing/app.d.ts +36 -0
  155. package/dist/testing/app.js +155 -0
  156. package/dist/testing/browser.d.ts +21 -0
  157. package/dist/testing/browser.js +21 -0
  158. package/dist/testing/connection.d.ts +7 -0
  159. package/dist/testing/connection.js +303 -0
  160. package/dist/testing/database.d.ts +21 -0
  161. package/dist/testing/database.js +54 -0
  162. package/dist/testing/env-example.d.ts +4 -0
  163. package/dist/testing/env-example.js +33 -0
  164. package/dist/testing/index.d.ts +9 -0
  165. package/dist/testing/index.js +9 -0
  166. package/dist/testing/shopify-connection.d.ts +8 -0
  167. package/dist/testing/shopify-connection.js +313 -0
  168. package/dist/testing/shopify.d.ts +71 -0
  169. package/dist/testing/shopify.js +278 -0
  170. package/dist/testing/smtp.d.ts +38 -0
  171. package/dist/testing/smtp.js +165 -0
  172. package/dist/testing/stripe.d.ts +128 -0
  173. package/dist/testing/stripe.js +511 -0
  174. package/dist/texts.d.ts +139 -0
  175. package/dist/texts.js +265 -0
  176. package/dist/ui/AccountPage.svelte +143 -0
  177. package/dist/ui/AccountPage.svelte.d.ts +10 -0
  178. package/dist/ui/ConfirmEmailPage.svelte +21 -0
  179. package/dist/ui/ConfirmEmailPage.svelte.d.ts +10 -0
  180. package/dist/ui/ErrorPage.svelte +14 -0
  181. package/dist/ui/ErrorPage.svelte.d.ts +3 -0
  182. package/dist/ui/Field.svelte +12 -0
  183. package/dist/ui/Field.svelte.d.ts +10 -0
  184. package/dist/ui/ForgotPasswordPage.svelte +32 -0
  185. package/dist/ui/ForgotPasswordPage.svelte.d.ts +7 -0
  186. package/dist/ui/Form.svelte +30 -0
  187. package/dist/ui/Form.svelte.d.ts +11 -0
  188. package/dist/ui/Frame.svelte +84 -0
  189. package/dist/ui/Frame.svelte.d.ts +12 -0
  190. package/dist/ui/LegalPage.svelte +38 -0
  191. package/dist/ui/LegalPage.svelte.d.ts +9 -0
  192. package/dist/ui/Linked.svelte +5 -0
  193. package/dist/ui/Linked.svelte.d.ts +8 -0
  194. package/dist/ui/LoginPage.svelte +41 -0
  195. package/dist/ui/LoginPage.svelte.d.ts +10 -0
  196. package/dist/ui/LogoutPage.svelte +19 -0
  197. package/dist/ui/LogoutPage.svelte.d.ts +9 -0
  198. package/dist/ui/ResetPasswordPage.svelte +30 -0
  199. package/dist/ui/ResetPasswordPage.svelte.d.ts +10 -0
  200. package/dist/ui/SignupPage.svelte +52 -0
  201. package/dist/ui/SignupPage.svelte.d.ts +10 -0
  202. package/dist/ui/VerifyEmailPage.svelte +50 -0
  203. package/dist/ui/VerifyEmailPage.svelte.d.ts +10 -0
  204. package/dist/ui/base.css +287 -0
  205. package/dist/ui/data.d.ts +167 -0
  206. package/dist/ui/data.js +1 -0
  207. package/dist/ui/frame.d.ts +17 -0
  208. package/dist/ui/frame.js +27 -0
  209. package/dist/ui/index.d.ts +15 -0
  210. package/dist/ui/index.js +15 -0
  211. package/dist/variables.d.ts +20 -0
  212. package/dist/variables.js +62 -0
  213. package/package.json +88 -4
@@ -0,0 +1,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
+ }[]>;