diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ad8187bffa..0c2777e7d9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -571,6 +571,84 @@ importers: specifier: ^5.9.3 version: 5.9.3 + spacetime-stripe-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + valibot: + specifier: ^1.4.2 + version: 1.4.2(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + stripe: + specifier: ^22.1.0 + version: 22.5.0(@types/node@22.18.0) + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-stripe-ts/example: + dependencies: + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-stripe-ts/example/spacetimedb: + dependencies: + '@spacetimedb/stripe': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + spacetime-submodule-shared-ts: devDependencies: '@types/node': @@ -13824,6 +13902,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@22.5.0: + resolution: {integrity: sha512-QVwMwriC0bbySx6R4dpsvJ0W//GojC1kwWVS6rPSoVqDUIZX4Hy3TaUrd2AZeXEAaKbfWIjQjvo3vKAReHZ0vQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + structured-clone-es@1.0.0: resolution: {integrity: sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ==} @@ -31475,6 +31562,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@22.5.0(@types/node@22.18.0): + optionalDependencies: + '@types/node': 22.18.0 + structured-clone-es@1.0.0: {} style-to-js@1.1.17: @@ -32355,6 +32446,10 @@ snapshots: optionalDependencies: typescript: 5.6.3 + valibot@1.4.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 diff --git a/spacetime-stripe-ts/.npmrc b/spacetime-stripe-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/LICENSE.txt b/spacetime-stripe-ts/LICENSE.txt new file mode 100644 index 00000000000..4e61a2122e7 --- /dev/null +++ b/spacetime-stripe-ts/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Clockwork Labs, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/spacetime-stripe-ts/README.md b/spacetime-stripe-ts/README.md new file mode 100644 index 00000000000..9c392afd861 --- /dev/null +++ b/spacetime-stripe-ts/README.md @@ -0,0 +1,242 @@ +# @spacetimedb/stripe + +A SpacetimeDB submodule that mirrors Stripe customers, subscriptions, Checkout +sessions, invoices, and payments. Stripe webhooks feed private base tables, and +host modules expose product-specific views and workflows. Procedures are +synchronous and webhook payloads use valibot validation. + +--- + +## Install + +```bash +npm install @spacetimedb/stripe spacetimedb +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This submodule can be published directly as its own SpacetimeDB module from the root entry point. + +## Usage + +### Integrate into an application + +Register Stripe in the application schema and initialize its private tables. The +host must place authorization in front of customer, Checkout, portal, and +billing procedures and expose only caller-scoped billing views: + +```ts +import { schema } from 'spacetimedb/server'; +import * as stripe from '@spacetimedb/stripe/submodule'; + +const spacetimedb = schema({ stripe }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + stripe.installStripe(ctx.as.stripe); +}); +``` + +Configure credentials through an administrator-only startup path. See the +[Premium Store host module](./example/spacetimedb/) +for service-identity delegation, safe return URLs, catalog synchronization, and +webhook routing. + +Expose a product-facing Checkout procedure that resolves the application user, +validates every price against a server-owned catalog, fixes the allowed return +URL origins, and delegates to Stripe: + +```ts +export const createStoreCheckoutSession = spacetimedb.procedure( + storeCheckoutParams, + storeCheckoutResult, + (ctx, args) => { + const checkout = authorizeStoreCheckout(ctx, args); + return stripe.createCheckoutSession(ctx.as.stripe, checkout); + } +); +``` + +`storeCheckoutParams`, `storeCheckoutResult`, and `authorizeStoreCheckout` +belong to the host application. The generated client calls that wrapper and +navigates only to the returned Stripe URL: + +```ts +const checkout = await conn.procedures.createStoreCheckoutSession({ + items: [{ priceId, quantity: 1n }], + customerId, + mode: 'payment', + successUrl: `${location.origin}/?checkout=success`, + cancelUrl: `${location.origin}/?checkout=cancelled`, + metadataJson: JSON.stringify({ cartId }), + subscriptionMetadataJson: undefined, + paymentIntentMetadataJson: undefined, +}); + +if (!checkout.url) throw new Error('stripe.checkout_url_missing'); +location.assign(checkout.url); +``` + +### Standalone configuration + +Stripe credentials live in a private `stripe_config` singleton. During `init`, a +fresh database seeds the owner into the private `stripe_admin_identity` table. + +```bash +spacetime call --server http://127.0.0.1:3000 stripe-ts set_stripe_config \ + '"sk_test_..."' \ + null \ + '{"some":"whsec_..."}' # webhook signing secret, optional +``` + +For `t.option(...)` CLI arguments, use `null` for no value and +`{"some":"value"}` for a string value. + +Verify: + +```bash +spacetime call --server http://127.0.0.1:3000 stripe-ts get_stripe_config_status '{}' +``` + +The Stripe secret stays in private module state. Every provider-backed, +billing-state, configuration, and query procedure is admin-gated. A host module +can perform application-specific authorization and then call the helpers through +its submodule-scoped `ctx.as.stripe` context. + +## Private tables + +| Table | Key | Notes | +| ------------------------- | ---------------------------- | ----------------------------------------- | +| `stripe_customer` | `stripe_customer_id` | indexed by email, app-userId | +| `stripe_subscription` | `stripe_subscription_id` | indexed by customer, org, user | +| `stripe_checkout_session` | `stripe_checkout_session_id` | indexed by customer, status | +| `stripe_invoice` | `stripe_invoice_id` | indexed by customer, subscription, status | +| `stripe_payment` | `stripe_payment_intent_id` | indexed by customer, status | + +- `stripe_webhook_event`: idempotency log (`get_webhook_event_count` exposes the size) +- `stripe_config`: credentials singleton +- `stripe_admin_identity`: admin allowlist + +Stripe base tables are private. Expose product-specific fields and rows through +caller-scoped host views. Resolve customer, user, and organization IDs from +trusted application context. + +## API + +**Setup** + +- `set_stripe_config(secretKey, stripeVersion, webhookSigningSecret)` +- `set_stripe_webhook_signing_secret(webhookSigningSecret)`: rotates only the webhook secret +- `get_stripe_config_status()`: returns `{ isConfigured, hasWebhookSecret, secretKeyLength, ... }` +- `add_admin_identity(identity)` / `remove_admin_identity(identity)` + +**Customer / billing flows** + +- `create_customer({ email, name, metadataJson, idempotencyKey })` +- `create_or_update_customer({ stripeCustomerId, email, name, metadataJson })` +- `get_or_create_customer({ userId, email, name})` +- `create_checkout_session({ items, mode, successUrl, cancelUrl, customerId, ...metadata })` +- `validate_stripe_price({ priceId })`: confirms a price exists and is active +- `get_remote_checkout_session({ sessionId })`: fetch session state from Stripe +- `create_customer_portal_session({ customerId, returnUrl })` +- `cancel_subscription({ stripeSubscriptionId, cancelAtPeriodEnd })` +- `reactivate_subscription({ stripeSubscriptionId })` +- `update_subscription_quantity({ stripeSubscriptionId, quantity })` +- `update_subscription_metadata({ stripeSubscriptionId, metadataJson, orgId, userId })` +- `stripe_api_request({ method, path, formBody, idempotencyKey })`: admin-gated + request to a relative `/v1/` path on `api.stripe.com`; accepted methods are + `GET`, `POST`, and `DELETE` + +**Webhook ingest / replay** + +- `ingest_stripe_webhook(eventId, eventType, livemode, payloadJson, signatureHeader)`: idempotent +- `replay_webhook_event(eventId)`: re-applies a stored event +- `get_webhook_event_count()`: observability +- `stripe_webhook_handler` and `handleStripeWebhook` support direct host HTTP + routing. +- `upsert_customer`, `upsert_subscription`, `update_payment_customer`, and + `update_subscription_quantity_internal` apply trusted synchronization data. + +**Admin queries** + +- `get_customer`, `get_customer_by_email`, `get_customer_by_user_id` +- `get_subscription`, `list_subscriptions`, `list_subscriptions_with_creation_time`, `get_subscription_by_org_id`, `list_subscriptions_by_org_id`, `list_subscriptions_by_user_id` +- `get_payment`, `list_payments`, `list_payments_by_org_id`, `list_payments_by_user_id` +- `list_invoices`, `list_invoices_by_org_id`, `list_invoices_by_user_id` +- `get_checkout_session`, `list_checkout_sessions` + +List procedures return at most 1,000 rows. Build paginated, product-specific +views in the host module when a UI needs a larger history. + +Package entrypoints: + +- `@spacetimedb/stripe` can run as a standalone billing database. +- `@spacetimedb/stripe/submodule` supplies submodule billing, webhook, + configuration, and query operations. + +## Webhook events handled + +``` +customer.created customer.updated +customer.subscription.created customer.subscription.updated +customer.subscription.deleted checkout.session.completed +invoice.created invoice.finalized +invoice.paid invoice.payment_succeeded +invoice.payment_failed payment_intent.succeeded +``` + +Other event types are accepted but stored with `status = 'ignored'`. + +## Webhook signature verification + +Both entry points verify the Stripe signature in-module against the configured +`webhookSigningSecret` (HMAC-SHA256 over `${timestamp}.${rawBody}` via +`@spacetimedb/crypto`). Missing secrets produce a service-unavailable response: + +- `stripe_webhook_handler` (HTTP) - for direct Stripe-to-SpacetimeDB delivery. +- `ingest_stripe_webhook` (reducer) - for a relay forwarding the raw body + + `stripe-signature` header over the SDK; it verifies before mutating state. + +`replay_webhook_event` re-applies an already-stored event and is admin-gated. +The relay reducer also verifies that its separately supplied event metadata +matches the signed payload before using the event ID as its idempotency key. +Webhook application is atomic. Invalid event data rolls back the event row and +business-table changes so Stripe can redeliver the event. + +## Integration testing + +```bash +# Build + publish + happy paths, idempotency, signed-metadata checks, and authorization checks +pnpm run test:smoke + +# Real Stripe sandbox via Stripe CLI (requires `stripe login`) +pnpm run test:stripe:e2e +``` + +The smoke test publishes only to the dedicated `stripe-ts-smoke-test` database. +The Stripe CLI E2E suite likewise defaults to the dedicated `stripe-ts-e2e` +database, forwards the original signed body, and rotates only that database's +ephemeral listener secret. + +## Architecture notes + +- **valibot for runtime validation.** `vStripeEvent` is a `v.variant('type', [...])` over the 12 supported event types. `parseWithSchema` returns a tagged result; `assertExhaustive` makes the typed `switch` compiler-checked. +- **SDK types, sync HTTP.** The `stripe` npm package supplies event types such as `Stripe.CustomerCreatedEvent`. Procedures use the synchronous `ctx.http.fetch` API through the request boundary in `submodule/http.ts`. +- **Compile-time SDK alignment.** `_align*` checks in `schema.ts` assert valibot output is structurally assignable to `Stripe.*Event`. If Stripe ships a breaking change, typecheck fails. +- **Idempotency.** Each webhook event is keyed by `event.id`; re-ingest is a no-op. `replay_webhook_event` applies the stored event state again. + +## Testing + +```bash +pnpm test +pnpm run lint +``` + +Credentialed sandbox coverage is described in **Integration testing** above. + +## License + +[Apache-2.0](./LICENSE.txt). diff --git a/spacetime-stripe-ts/example/.env.example b/spacetime-stripe-ts/example/.env.example new file mode 100644 index 00000000000..51fd88a7224 --- /dev/null +++ b/spacetime-stripe-ts/example/.env.example @@ -0,0 +1,32 @@ +# Stripe setup. Loaded by the authorized example server during startup. +STRIPE_SECRET_KEY=sk_test_... + +# Optional webhook signature verification and API-version override. +STRIPE_WEBHOOK_SECRET= +STRIPE_VERSION= + +# Opt in to creating/linking Stripe test prices during startup. +STRIPE_SYNC_PRICES=0 + +# Browser provider actions are enabled automatically only for a non-production +# loopback host. Otherwise, opt in only after adding authentication/rate limits. +STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS=0 + +# Server-owned Checkout success/cancel origin. Production requires HTTPS. +STRIPE_RETURN_BASE_URL=http://127.0.0.1:8787 + +# Keep this set to development for local use. Production disables automatic +# loopback-only provider actions unless they are explicitly authorized below. +NODE_ENV=development + +# Optional: override DB target. +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-stripe-example +# Optional. When unset, the server creates a persistent local identity token in +# .stdb-server-token and the logged-in publishing identity authorizes it. +# STDB_SERVER_TOKEN= + +# Optional. +PORT=8787 +HOST=127.0.0.1 diff --git a/spacetime-stripe-ts/example/.npmrc b/spacetime-stripe-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/example/README.md b/spacetime-stripe-ts/example/README.md new file mode 100644 index 00000000000..1dbdc432fe2 --- /dev/null +++ b/spacetime-stripe-ts/example/README.md @@ -0,0 +1,204 @@ +# Premium Store + +Premium Store demonstrates a host database that mounts +`@spacetimedb/stripe/submodule` and delegates customer, price, checkout, and +webhook operations through the `stripe` namespace. The browser can shop and create +Stripe Checkout sessions, but it cannot configure Stripe or mutate administrative +catalog state. + +## What this demonstrates + +- Mounting the Stripe submodule inside an application-owned store module. +- Keeping Stripe credentials in private module state. +- Using a narrow server API for customer lookup, price validation, and Checkout; + the browser never receives the privileged service identity. +- Seeding an application catalog independently of Stripe provider records. +- Creating or linking idempotent Stripe test prices during explicit server setup. +- Receiving Stripe webhooks through the module's native HTTP route. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- A SpacetimeDB CLI and server built from this checkout, with the CLI available as `spacetime`. +- A local SpacetimeDB server reachable as `local`. +- A logged-in CLI identity that publishes the database. +- A Stripe **test-mode** secret key (`sk_test_...`). +- Optional: the Stripe CLI or another tunnel for forwarding test webhooks. + +This example uses the workspace SDK. Keep the matching local server running in +a separate terminal: + +```powershell +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-stripe-ts/example`: + +```powershell +pnpm install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +``` + +Set `STRIPE_SECRET_KEY=sk_test_...` in `.env`. Set +`STRIPE_SYNC_PRICES=1` for the first full checkout run; this creates or reuses +three Stripe test prices and writes their IDs to `store_product`. + +```powershell +pnpm run build:module:fresh +pnpm run dev +``` + +Open . Add a product to the cart and create a Stripe test +Checkout session. No charge occurs unless the Checkout page is completed with a +Stripe test payment method. + +`build:module:fresh` deletes and recreates only the local `spacetime-stripe-example` +database. Use `pnpm run build:module` to preserve existing data. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/stripe spacetimedb +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +service-identity, narrow Checkout API, caller-scoped billing views, and signed +webhook route. The product catalog and storefront are demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| --------------------------------------- | ------------------------------------ | ------------------------------------------------------------- | +| `STRIPE_SECRET_KEY` | empty | Required for provider operations. Use a test-mode key. | +| `STRIPE_WEBHOOK_SECRET` | empty | Verifies incoming Stripe webhook signatures. | +| `STRIPE_VERSION` | submodule default | Optional Stripe API-version override. | +| `STRIPE_SYNC_PRICES` | `0` | Set to `1` to create/link missing test prices during startup. | +| `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS` | automatic on non-production loopback | Explicit provider-action opt-in for other environments. | +| `STRIPE_RETURN_BASE_URL` | `http://127.0.0.1:8787` | Server-owned Checkout return origin. | +| `NODE_ENV` | empty | Set to `production` to disable development-only defaults. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | CLI administration endpoint. Must match `STDB_URI`. | +| `SPACETIMEDB_DB_NAME` | `spacetime-stripe-example` | Published database name. | +| `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | +| `HOST` | `127.0.0.1` | Static-server bind address. | +| `PORT` | `8787` | Static-server port. | + +When no server token is supplied, the server persists one in the ignored +`.stdb-server-token` file. The logged-in publishing identity registers that server +identity in both the host store and Stripe submodule administrator registries. The +browser identity is never granted either role. + +## Startup behavior + +The example server performs the following bounded setup before accepting HTTP: + +1. Connect with the persistent server identity. +2. Authorize it through the logged-in CLI publishing identity. +3. Seed the default store catalog if it is empty. +4. Store Stripe configuration when `STRIPE_SECRET_KEY` is present. +5. Synchronize missing prices only when `STRIPE_SYNC_PRICES=1`. + +Price synchronization is opt-in because it creates test-mode objects in the linked +Stripe account. Existing prices use stable lookup keys and are reused. + +## Architecture + +```text +Browser storefront + -> public store_product subscription + -> same-origin /api checkout/customer/validation routes + -> authorized server identity + -> host checkout/customer/validation procedures + -> stripe submodule namespace + -> Stripe API + +Stripe + -> POST /route/stripe/webhook on the SpacetimeDB database + -> host router + -> stripe submodule webhook handler + +Authorized example server + -> private configuration and catalog setup during startup +``` + +The Node server exposes only browser-safe health and configuration routes: + +| Route | Purpose | +| ----------------- | ----------------------------------- | +| `GET /api/health` | Local health probe. | +| `GET /api/config` | Browser-safe database/setup status. | + +There are no HTTP administration endpoints. The settings panel contains buyer and +debugging controls only. + +## Webhooks + +Forward Stripe test events to the database's native route: + +```text +http://127.0.0.1:3000/v1/database/spacetime-stripe-example/route/stripe/webhook +``` + +Use the signing secret produced by the forwarding tool as +`STRIPE_WEBHOOK_SECRET`, then restart so the private submodule configuration is +updated. + +## Security and deployment boundaries + +- Never use a live-mode Stripe key for casual example testing. +- Stripe secrets and the persistent server token must never be committed or sent to + the browser. +- The server binds to loopback by default. +- Production deployments should provision an authenticated service identity + through deployment infrastructure. +- Browser provider actions are automatic only for a non-production loopback host. + Production and externally bound development servers default to disabled. Add + application authentication and rate limiting, then set + `STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS=1` only when required. +- The server owns Checkout return URLs. Set `STRIPE_RETURN_BASE_URL` to the public + HTTPS origin in production; browser-supplied redirect URLs are ignored. +- Checkout success in the UI is a redirect result; authoritative fulfillment must + come from verified webhooks. + +## Verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For the provider-backed smoke test, set `STRIPE_SYNC_PRICES=1`, fresh-publish, +start the server, confirm three prices synchronize, add a product to the cart, and +create a test Checkout session. + +## Troubleshooting + +- **Products say “Sync price first”:** set `STRIPE_SYNC_PRICES=1` and restart with + a valid test key. +- **`stripe.not_authorized`:** publish with the logged-in CLI identity and restart; + both the store and Stripe namespaces must authorize the server identity. +- **Connection targets disagree:** make `STDB_URI`, `STDB_HTTP`, and the publish + target refer to the same server. +- **Webhook state is stale:** verify the forwarding URL and + `STRIPE_WEBHOOK_SECRET`. + +## Important files + +- `spacetimedb/src/store/operations.ts`: application catalog and Stripe + delegation. +- `server.ts`: safe startup configuration and server identity authorization. +- `src/app.ts`: typed browser-side SpacetimeDB adapter. +- `public/index.html`: storefront and buyer tools. +- `public/ui.js`: storefront state, rendering, and interaction handling. +- `public/styles.css`: storefront presentation. diff --git a/spacetime-stripe-ts/example/package.json b/spacetime-stripe-ts/example/package.json new file mode 100644 index 00000000000..90d0a5087b6 --- /dev/null +++ b/spacetime-stripe-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-stripe-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-stripe-ts/example/public/index.html b/spacetime-stripe-ts/example/public/index.html new file mode 100644 index 00000000000..dd34614603f --- /dev/null +++ b/spacetime-stripe-ts/example/public/index.html @@ -0,0 +1,155 @@ + + + + + + + SpacetimeDB Premium Store + + + +
+
+
+ SpacetimeDB + Premium Store Test App +
+
+ + +
+
+ +
+
+

Storefront

+

+ Browse products and add them to cart. Open cart to checkout. +

+
+
Loading catalog from SpacetimeDB...
+
+
+
+ + +
+ + + + + +
+ + + + + + + + diff --git a/spacetime-stripe-ts/example/public/styles.css b/spacetime-stripe-ts/example/public/styles.css new file mode 100644 index 00000000000..cc0996a0bbc --- /dev/null +++ b/spacetime-stripe-ts/example/public/styles.css @@ -0,0 +1,1169 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + /* Tokens match spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); +} + +body::before { + content: none; +} + +/* Compact scrollbars matching the SpacetimeDB dashboard. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) var(--color-shade7); +} +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 2px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade3); +} +*::-webkit-scrollbar-corner { + background: var(--color-shade7); +} + +.shell { + width: min(1320px, calc(100% - 32px)); + margin: 14px auto 30px; +} + +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + padding: 9px 12px; + box-shadow: inset 0 1px 0 #26435166; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.brand-wordmark { + display: block; + height: 28px; +} + +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.topnav-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.pill-link, +.meta-pill { + border: 1px solid #27414e; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #c6d1e1; +} + +.meta-pill.good { + border-color: #31684c; + color: var(--color-green); +} + +.pill-link { + text-decoration: none; + transition: + border-color 120ms ease, + color 120ms ease; +} + +.pill-link:hover { + border-color: #3b6173; + color: #e6edf7; +} + +.cart-button { + width: auto; + border: 1px solid #2f5363; + border-radius: 999px; + background: #112433; + color: #d4deea; + padding: 7px 12px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.icon-button { + width: 34px; + height: 34px; + border: 1px solid #2f5363; + border-radius: 999px; + background: #112433; + color: #d4deea; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.icon-button svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.subbar { + margin-top: 10px; + border: 1px solid #1a3541; + border-radius: 12px; + background: linear-gradient(180deg, #10202bde, #0d1720de); + padding: 10px 12px; +} + +.status-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.status-pill { + border: 1px solid #27414e; + border-radius: 999px; + padding: 5px 11px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #c4d0e0; +} + +.status-pill.good { + border-color: #2f644a; + color: var(--color-green); +} + +.status-pill.warn { + border-color: #5b5737; + color: var(--color-yellow); +} + +.layout { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} + +.panel { + border: 1px solid #17313d; + border-radius: 16px; + background: linear-gradient(180deg, #0f1d27e6, #0c161dde); + box-shadow: inset 0 1px 0 #2a435033; + padding: 14px; +} + +.panel h1, +.panel h2 { + margin: 0; + font-size: 31px; + letter-spacing: -0.03em; + line-height: 1.1; + font-weight: 500; +} + +.panel-sub { + margin: 5px 0 12px; + color: var(--color-n3); + font-size: 17px; +} + +.catalog-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.product-card { + border: 1px solid #203846; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #111f29, #0d1820); + padding: 12px; + display: flex; + flex-direction: column; + transition: + border-color 100ms ease, + transform 100ms ease, + box-shadow 100ms ease; +} + +.product-card:hover { + border-color: #2f5363; + transform: translateY(-1px); + box-shadow: 0 10px 22px #0000002e; +} + +.product-row { + display: flex; + flex-direction: column; + gap: 10px; + align-items: stretch; + height: 100%; +} + +.product-media { + height: 96px; + border: 1px solid #264353; + border-radius: 10px; + background: + radial-gradient(circle at 24% 22%, #4cf49044, transparent 45%), + radial-gradient(circle at 75% 78%, #02befa33, transparent 44%), + linear-gradient(140deg, #112738, #0f1d29); +} + +.product-head { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.badge-row { + display: flex; + align-items: center; + gap: 6px; +} + +.product-copy { + display: grid; + gap: 6px; +} + +.product-rating { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #a8bdd4; + min-height: 18px; +} + +.product-stars { + color: #f59e0b; + letter-spacing: 0.04em; +} + +.card-actions { + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid #1b3140; +} + +.btn.add-cart { + width: auto; + min-width: 140px; + background: linear-gradient(90deg, #4cf490, #02befa); + border-color: #4cf490; + color: #03131b; + font-weight: 700; +} + +.card-actions .btn { + font-size: 11px; + padding: 9px 10px; +} + +.card-stepper { + display: inline-flex; + align-items: center; + border: 2px solid #2f664d; + border-radius: 999px; + overflow: hidden; + background: #0f1f2a; + min-height: 40px; +} + +.stepper-btn { + width: 40px; + height: 36px; + border: 0; + background: transparent; + color: #d6e5f6; + font-size: 22px; + line-height: 1; + cursor: pointer; +} + +.stepper-btn:hover { + background: #16303d; +} + +.stepper-btn.is-trash { + font-size: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.stepper-btn .trash-icon { + width: 15px; + height: 15px; + stroke: #e8f1fb; + fill: none; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.stepper-count { + padding: 0 14px; + font-family: var(--font-ibm); + font-size: 12px; + letter-spacing: 0.04em; + color: #d6e5f6; +} + +.card-stepper.bump { + animation: stepper-bump 220ms ease; +} + +@keyframes stepper-bump { + 0% { + transform: scale(1); + } + 45% { + transform: scale(1.04); + } + 100% { + transform: scale(1); + } +} + +.cart-stepper .stepper-count { + min-width: 98px; + text-align: center; +} + +.card-top { + display: flex; + justify-content: flex-start; + align-items: baseline; + gap: 8px; + margin-top: 0; +} + +.merch-badge { + border: 1px solid #2f4f5f; + border-radius: 999px; + padding: 2px 8px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #9fb2c8; + width: fit-content; +} + +.merch-badge.sale { + border-color: #3b5c41; + color: #7ee6ad; + background: #4cf49014; +} + +.merch-badge.mode { + border-color: #2d4b5a; + color: #9fb2c8; + background: #10212b; +} + +.price-label { + font-size: 24px; + letter-spacing: -0.03em; + font-weight: 700; + line-height: 1; +} + +.price-stack { + display: inline-flex; + align-items: baseline; + gap: 10px; + text-align: left; + flex-wrap: wrap; +} + +.price-compare { + margin-top: 0; + font-size: 13px; + color: #8ea3bc; +} + +.price-compare s { + color: #7389a2; +} + +.product-title { + margin: 0; + font-size: 18px; + line-height: 1.25; + letter-spacing: -0.02em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.product-description { + margin: 0; + color: var(--color-n3); + font-size: 12px; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.form-row { + display: flex; + flex-direction: column; + gap: 5px; +} + +.form-row.full { + grid-column: 1 / -1; +} + +.help-text { + margin-top: 6px; + color: #88a5ba; + font-size: 12px; +} + +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +label { + color: #9eb2cb; + font-size: 13px; +} + +input, +button, +textarea { + width: 100%; + border-radius: 8px; + box-sizing: border-box; + font-family: inherit; +} + +input, +textarea { + border: 1px solid #213947; + background: #0a1217; + color: var(--color-white); + padding: 11px 12px; +} + +input:focus, +textarea:focus { + outline: 1px solid #2c4e5f; +} + +.summary { + margin-top: 10px; + border: 1px solid #203846; + border-radius: 12px; + background: #0d1720; + padding: 10px; +} + +.summary-title { + font-size: 14px; + color: var(--color-n2); +} + +.summary-value { + margin-top: 4px; + font-size: 26px; + letter-spacing: -0.03em; +} + +.summary-meta { + margin-top: 6px; + color: #9ab0cb; + font-family: var(--font-ibm); + font-size: 12px; +} + +.cart-meta { + margin-top: 4px; + color: #9ab0cb; + font-family: var(--font-ibm); + font-size: 11px; +} + +.shop-actions { + margin-top: 12px; + display: flex; + gap: 8px; +} + +.price-status { + margin-top: 8px; + display: inline-flex; + align-items: center; + gap: 7px; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid #2f4552; + color: #b8c7d8; +} + +.price-status.good { + border-color: #336b50; + color: var(--color-green); +} + +.price-status.bad { + border-color: #65434f; + color: var(--color-orange); +} + +.price-status.warn { + border-color: #5e5b40; + color: var(--color-yellow); +} + +.actions { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.action-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.btn { + border: 1px solid #2c4957; + border-radius: 9px; + padding: 11px 12px; + cursor: pointer; + font-family: var(--font-ibm); + font-size: 12px; + letter-spacing: 0.11em; + text-transform: uppercase; + transition: + filter 120ms ease, + transform 80ms ease; +} + +.btn:active { + transform: translateY(1px); +} + +/* .btn.primary matches spacetimedb.com Button.module.css: + n3 bg, n8 text, white on hover, green on active, green focus outline. */ +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); + font-weight: 600; +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); + filter: none; +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +.btn.primary:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.btn.secondary { + background: transparent; + color: #d2dce8; +} + +.btn.ghost { + background: #101f29; + color: #ccd8e8; +} + +.btn:hover { + filter: brightness(1.08); +} + +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.56; + cursor: not-allowed; +} + +.dev-tools { + padding: 10px; +} + +.dev-tools .form-grid { + margin-bottom: 8px; +} + +textarea { + min-height: 170px; + resize: vertical; + font-family: var(--font-ibm); + font-size: 12px; + line-height: 1.35; +} + +#errorLine { + position: fixed; + top: 18px; + left: 50%; + transform: translateX(-50%); + z-index: 80; + pointer-events: none; + max-width: min(420px, calc(100% - 44px)); +} +#errorLine:empty { + display: none; +} +.error-line-msg { + pointer-events: auto; + padding: 10px 14px; + border-radius: var(--radius-lg); + font-size: 13px; + font-family: var(--font-ibm); + color: var(--color-orange); + background: rgba(255, 158, 158, 0.08); + border: 1px solid rgba(255, 158, 158, 0.45); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); + animation: errorLineSlideIn 180ms ease; + cursor: pointer; +} +@keyframes errorLineSlideIn { + from { + opacity: 0; + transform: translateY(-12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.checkout-banner { + position: fixed; + left: 50%; + bottom: 22px; + transform: translate(-50%, 24px); + opacity: 0; + pointer-events: none; + z-index: 70; + max-width: min(560px, calc(100% - 32px)); + padding: 12px 18px; + border-radius: 12px; + font-size: 14px; + color: var(--color-white); + background: linear-gradient(180deg, #0f2a35, #0c1f28); + border: 1px solid #1c4856; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + gap: 12px; + transition: + transform 220ms ease, + opacity 220ms ease; +} +.checkout-banner.is-visible { + opacity: 1; + transform: translate(-50%, 0); + pointer-events: auto; +} +.checkout-banner.success { + border-color: #2f7d52; + box-shadow: + 0 18px 40px rgba(0, 0, 0, 0.5), + 0 0 0 1px #2f7d5240; +} +.checkout-banner.success .checkout-banner-dot { + background: var(--color-green); + box-shadow: 0 0 0 4px rgba(75, 244, 144, 0.18); +} +.checkout-banner.canceled { + border-color: #6a4f1c; +} +.checkout-banner.canceled .checkout-banner-dot { + background: var(--color-yellow); + box-shadow: 0 0 0 4px rgba(251, 220, 142, 0.18); +} +.checkout-banner-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} +.checkout-banner-text { + flex: 1 1 auto; + min-width: 0; + line-height: 1.45; +} +.checkout-banner-text strong { + font-weight: 600; +} +.checkout-banner button.checkout-banner-close { + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + background: transparent; + border: 1px solid #2a4250; + color: var(--color-n3); + font-size: 14px; + line-height: 1; + border-radius: 6px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.checkout-banner button.checkout-banner-close:hover { + border-color: #3a5666; + color: var(--color-white); +} + +.dev-tools-backdrop { + position: fixed; + inset: 0; + background: #04080bb8; + backdrop-filter: blur(2px); + z-index: 50; +} + +.dev-tools-popout { + position: fixed; + top: 0; + right: 0; + width: min(540px, 100%); + height: 100vh; + z-index: 60; + border-left: 1px solid #1f3845; + background: linear-gradient(180deg, #0f1d27f7, #0c161df5); + box-shadow: -16px 0 40px #00000066; + display: flex; + flex-direction: column; +} + +.dev-tools-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid #1f3845; +} + +.dev-tools-title { + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.09em; + font-size: 12px; + color: #9eb3cd; +} + +.dev-tools-close { + width: auto; + padding: 6px 10px; + font-size: 11px; +} + +.dev-tools-body { + overflow-y: auto; + padding: 2px 0 12px; +} + +.dev-tools-backdrop[hidden], +.dev-tools-popout[hidden] { + display: none; +} + +.cart-backdrop { + position: fixed; + inset: 0; + background: #04080bb8; + backdrop-filter: blur(2px); + z-index: 40; +} + +.cart-popout { + position: fixed; + top: 0; + right: 0; + width: min(560px, 100%); + height: 100vh; + z-index: 45; + border-left: 1px solid #1f3845; + background: linear-gradient(180deg, #0f1d27f7, #0c161df5); + box-shadow: -16px 0 40px #00000066; + display: flex; + flex-direction: column; +} + +.cart-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid #1f3845; +} + +.cart-title { + font-family: var(--font-ibm); + text-transform: uppercase; + letter-spacing: 0.09em; + font-size: 12px; + color: #9eb3cd; +} + +.cart-body { + padding: 12px; + height: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} + +.cart-list { + display: grid; + gap: 8px; + flex: 1; + overflow-y: auto; + padding-right: 2px; + align-content: start; + grid-auto-rows: max-content; +} + +.cart-item { + border: 1px solid #1f3845; + border-radius: 10px; + background: #0d1820; + padding: 9px; +} + +.cart-item-top { + display: grid; + grid-template-columns: 44px 1fr auto; + gap: 10px; + align-items: center; +} + +.cart-item-thumb { + width: 44px; + height: 44px; + border-radius: 10px; + border: 1px solid #2a4757; + background: radial-gradient( + circle at 30% 28%, + #4cf49066, + #02befa2b 55%, + #0f1920 100% + ); +} + +.cart-item-content { + min-width: 0; +} + +.cart-item-name { + font-size: 16px; + font-weight: 600; + line-height: 1.2; +} + +.cart-item-meta { + font-family: var(--font-ibm); + font-size: 11px; + color: #9eb3cd; + margin-top: 4px; +} + +.cart-item-sub { + font-family: var(--font-ibm); + font-size: 11px; + color: #b8c6d7; + margin-top: 4px; +} + +.cart-item-controls { + justify-self: end; +} + +.cart-item-top .btn { + width: auto; + padding: 6px 10px; + font-size: 11px; +} + +.cart-empty { + border: 1px dashed #2d4656; + border-radius: 10px; + padding: 18px 14px; + color: #98abc2; + min-height: 180px; + display: grid; + align-content: center; + justify-items: center; + text-align: center; + gap: 8px; +} + +.cart-empty-title { + font-size: 18px; + letter-spacing: -0.02em; + color: #d7d8d9; +} + +.cart-empty-copy { + font-size: 13px; + color: #9ab0cb; + max-width: 260px; +} + +.cart-footer { + border-top: 1px solid #1f3845; + padding-top: 10px; + display: grid; + gap: 10px; +} + +.cart-summary { + display: flex; + align-items: center; + justify-content: space-between; + font-family: var(--font-ibm); + font-size: 11px; + color: #9ab0cb; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.cart-backdrop[hidden], +.cart-popout[hidden] { + display: none; +} + +.loading-card { + grid-column: 1 / -1; + border: 1px dashed #2d4656; + border-radius: 12px; + padding: 14px; + color: #98abc2; + font-family: var(--font-ibm); +} + +.skeleton-card { + border: 1px solid #203846; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #111f29, #0d1820); + padding: 12px; + display: grid; + gap: 8px; +} + +.skeleton-line { + border-radius: 7px; + background: linear-gradient(100deg, #132735 0%, #1c3342 38%, #132735 76%); + background-size: 220% 100%; + animation: skeleton-shimmer 1.25s linear infinite; +} + +.skeleton-media { + height: 96px; + border-radius: 10px; +} + +.skeleton-line.badges { + height: 18px; + width: 44%; +} + +.skeleton-line.title { + height: 22px; + width: 76%; +} + +.skeleton-line.desc { + height: 14px; + width: 92%; +} + +.skeleton-line.meta { + height: 14px; + width: 50%; +} + +.skeleton-line.price { + height: 28px; + width: 40%; +} + +.skeleton-line.actions { + height: 40px; + width: 100%; + margin-top: 8px; +} + +@keyframes skeleton-shimmer { + from { + background-position: 220% 0; + } + to { + background-position: -30% 0; + } +} + +@media (max-width: 980px) { + .layout { + grid-template-columns: 1fr; + } + .catalog-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .shell { + width: calc(100% - 20px); + } + .catalog-grid { + grid-template-columns: 1fr; + } + .form-grid { + grid-template-columns: 1fr; + } + .brand-wordmark { + height: 24px; + } + .product-media { + height: 120px; + } + .card-actions { + gap: 6px; + } +} diff --git a/spacetime-stripe-ts/example/public/ui.js b/spacetime-stripe-ts/example/public/ui.js new file mode 100644 index 00000000000..00c4e802500 --- /dev/null +++ b/spacetime-stripe-ts/example/public/ui.js @@ -0,0 +1,766 @@ +const STORAGE_KEYS = { + cart: 'stdb.premiumStore.cart.v1', +}; + +const state = { + catalog: [], + cart: [], + checkoutPending: false, + animatedStepperKey: null, +}; + +const byId = id => document.getElementById(id); + +const HTML_ESCAPE = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, character => HTML_ESCAPE[character]); +} + +const ui = { + btnCart: byId('btnCart'), + btnSettings: byId('btnSettings'), + cartBackdrop: byId('cartBackdrop'), + cartPopout: byId('cartPopout'), + btnCartClose: byId('btnCartClose'), + btnContinueShopping: byId('btnContinueShopping'), + cartList: byId('cartList'), + cartSummary: byId('cartSummary'), + catalogGrid: byId('catalogGrid'), + email: byId('email'), + name: byId('name'), + userId: byId('userId'), + customerId: byId('customerId'), + btnCheckoutCart: byId('btnCheckoutCart'), + btnDevToolsClose: byId('btnDevToolsClose'), + btnCustomer: byId('btnCustomer'), + devToolsBackdrop: byId('devToolsBackdrop'), + devToolsPopout: byId('devToolsPopout'), + errorLine: byId('errorLine'), + log: byId('log'), + checkoutBanner: byId('checkoutBanner'), + checkoutBannerText: byId('checkoutBannerText'), + checkoutBannerClose: byId('checkoutBannerClose'), +}; + +let errorLineTimer = null; +function setError(message) { + if (errorLineTimer) { + clearTimeout(errorLineTimer); + errorLineTimer = null; + } + if (!message) { + ui.errorLine.innerHTML = ''; + return; + } + ui.errorLine.innerHTML = ''; + const pill = document.createElement('div'); + pill.className = 'error-line-msg'; + pill.textContent = message; + pill.addEventListener('click', () => setError('')); + ui.errorLine.appendChild(pill); + errorLineTimer = setTimeout(() => setError(''), 6000); +} + +let checkoutBannerTimer = null; +function showCheckoutBanner(kind, message) { + if (!ui.checkoutBanner) return; + ui.checkoutBanner.hidden = false; + ui.checkoutBanner.classList.remove('success', 'canceled'); + ui.checkoutBanner.classList.add(kind); + ui.checkoutBannerText.innerHTML = message; + void ui.checkoutBanner.offsetWidth; + ui.checkoutBanner.classList.add('is-visible'); + if (checkoutBannerTimer) clearTimeout(checkoutBannerTimer); + checkoutBannerTimer = setTimeout(hideCheckoutBanner, 8000); +} +function hideCheckoutBanner() { + if (!ui.checkoutBanner) return; + ui.checkoutBanner.classList.remove('is-visible'); + if (checkoutBannerTimer) { + clearTimeout(checkoutBannerTimer); + checkoutBannerTimer = null; + } +} +function consumePostCheckoutQueryFlags() { + const url = new URL(window.location.href); + const purchased = url.searchParams.get('purchased') === '1'; + const canceled = url.searchParams.get('canceled') === '1'; + if (!purchased && !canceled) return; + + if (purchased) { + state.cart = []; + state.animatedStepperKey = null; + persistCart(); + showCheckoutBanner( + 'success', + 'Checkout complete. Stripe confirmed the session.' + ); + } else { + showCheckoutBanner( + 'canceled', + 'Checkout canceled. No charge was made. Your cart is still here.' + ); + } + url.searchParams.delete('purchased'); + url.searchParams.delete('canceled'); + window.history.replaceState({}, '', url.toString()); +} + +function writeLog(message) { + const time = new Date().toLocaleTimeString(); + ui.log.value = `[${time}] ${message}\n\n` + ui.log.value; +} + +function setButtonLoading(button, isLoading, loadingText = 'Loading...') { + if (!button) return; + if (isLoading) { + if (!button.dataset.originalText) { + button.dataset.originalText = button.textContent || ''; + } + button.textContent = loadingText; + button.disabled = true; + return; + } + + if (button.dataset.originalText) { + button.textContent = button.dataset.originalText; + delete button.dataset.originalText; + } + button.disabled = false; +} + +function closeDevTools() { + ui.devToolsBackdrop.hidden = true; + ui.devToolsPopout.hidden = true; +} + +function openDevTools() { + ui.devToolsBackdrop.hidden = false; + ui.devToolsPopout.hidden = false; +} + +function openCart() { + ui.cartBackdrop.hidden = false; + ui.cartPopout.hidden = false; +} + +function missingPriceMessage(productName) { + return `Missing Stripe price ID for ${productName}. Set STRIPE_SYNC_PRICES=1 and restart the example server.`; +} + +function closeCart() { + ui.cartBackdrop.hidden = true; + ui.cartPopout.hidden = true; +} + +function parsePositiveInteger(value, fallback = 1) { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function parseAmountFromPriceLabel(priceLabel) { + const text = String(priceLabel ?? ''); + const match = text.match(/-?\d[\d,]*(?:\.\d{1,2})?/); + if (!match) return 0; + const normalized = match[0].replace(/,/g, ''); + const parsed = Number.parseFloat(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +function getMerchandising(item) { + const byId = { + 'orbital-starter-pack': { + badge: 'Best Seller', + rating: 4.8, + reviews: 214, + }, + 'warp-pass': { + badge: 'Limited Offer', + compareAt: 12, + rating: 4.6, + reviews: 129, + }, + 'fleet-command-bundle': { rating: 4.7, reviews: 88 }, + }; + return byId[item.id] || null; +} + +function formatUsd(amount) { + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `$${amount.toFixed(2)}`; + } +} + +async function api(path, payload) { + const response = await fetch(path, { + method: payload ? 'POST' : 'GET', + headers: payload ? { 'content-type': 'application/json' } : undefined, + body: payload ? JSON.stringify(payload) : undefined, + }); + const data = await response.json(); + if (!response.ok || data.ok === false) { + throw new Error(data.error || `Request failed (${response.status})`); + } + return data; +} + +function buildPostCheckoutUrl(flag) { + const url = new URL(window.location.origin); + url.searchParams.set(flag, '1'); + return url.toString(); +} + +function makeDefaultUserId() { + return `pilot_${crypto.randomUUID().slice(0, 8)}`; +} + +function seedDefaultBuyerDetails() { + ui.email.value = 'pilot@spacetime.dev'; + ui.name.value = 'Orbital Pilot'; + ui.userId.value = makeDefaultUserId(); +} + +function hydrateCart() { + try { + const raw = localStorage.getItem(STORAGE_KEYS.cart); + if (!raw) return; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return; + + state.cart = parsed + .map(item => ({ + id: String(item?.id || ''), + name: String(item?.name || ''), + priceLabel: String(item?.priceLabel || ''), + mode: item?.mode === 'subscription' ? 'subscription' : 'payment', + priceId: String(item?.priceId || ''), + quantity: parsePositiveInteger(item?.quantity, 1), + })) + .filter(item => item.id && item.name && item.priceId); + } catch { + state.cart = []; + } +} + +function persistCart() { + try { + localStorage.setItem(STORAGE_KEYS.cart, JSON.stringify(state.cart)); + } catch { + // Storage may be unavailable in private windows. + } +} + +function getCartEntry(itemId, priceId) { + return state.cart.find( + entry => entry.id === itemId && entry.priceId === priceId + ); +} + +function getCartQuantity(itemId, priceId) { + const entry = getCartEntry(itemId, priceId); + return entry ? entry.quantity : 0; +} + +function getCartKey(itemId, priceId) { + return `${itemId}::${priceId}`; +} + +function syncCartState() { + const count = state.cart.length; + const totalQty = state.cart.reduce((sum, item) => sum + item.quantity, 0); + const oneTimeSubtotal = state.cart.reduce((sum, item) => { + if (item.mode !== 'payment') return sum; + return sum + parseAmountFromPriceLabel(item.priceLabel) * item.quantity; + }, 0); + const recurringMonthly = state.cart.reduce((sum, item) => { + if (item.mode !== 'subscription') return sum; + return sum + parseAmountFromPriceLabel(item.priceLabel) * item.quantity; + }, 0); + + const cartModes = new Set(state.cart.map(entry => entry.mode)); + const isMixedMode = cartModes.size > 1; + const hasMissingPrice = state.cart.some(entry => !entry.priceId); + if (state.checkoutPending) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = 'Redirecting to Stripe...'; + } else if (hasMissingPrice) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = 'Sync Stripe prices first'; + } else if (isMixedMode) { + ui.btnCheckoutCart.disabled = true; + ui.btnCheckoutCart.textContent = "Can't mix one-time + subscription"; + } else { + ui.btnCheckoutCart.disabled = count === 0; + ui.btnCheckoutCart.textContent = + totalQty > 0 + ? `Checkout with Stripe (${totalQty})` + : 'Checkout with Stripe'; + } + if (ui.btnCart) ui.btnCart.textContent = `Cart (${totalQty})`; + if (ui.cartSummary) { + ui.cartSummary.innerHTML = + `Subtotal: ${formatUsd(oneTimeSubtotal)}` + + `Recurring: ${formatUsd(recurringMonthly)}/mo`; + } + persistCart(); + renderCartList(); + if (state.catalog.length > 0) renderCatalog(); +} + +function renderCartList() { + if (state.cart.length === 0) { + ui.cartList.innerHTML = ` +
+
Your cart is empty
+
Add items from the storefront to start checkout.
+
+ `; + return; + } + + ui.cartList.innerHTML = state.cart + .map(item => { + const unitPrice = parseAmountFromPriceLabel(item.priceLabel); + const lineTotal = unitPrice * item.quantity; + const cartKey = getCartKey(item.id, item.priceId); + const itemId = escapeHtml(item.id); + const itemName = escapeHtml(item.name); + const priceId = escapeHtml(item.priceId); + const stepperClass = + state.animatedStepperKey === cartKey + ? ' card-stepper bump cart-stepper' + : ' card-stepper cart-stepper'; + return ` +
+
+ +
+
${itemName}
+
${item.mode === 'subscription' ? 'Subscription' : 'One-time payment'}
+
${formatUsd(unitPrice)} x ${item.quantity} = ${formatUsd(lineTotal)}${item.mode === 'subscription' ? ' /mo' : ''}
+
+
+
+ +
${item.quantity} in cart
+ +
+
+
+
+ `; + }) + .join(''); + + for (const button of ui.cartList.querySelectorAll('.js-cart-plus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const item = state.catalog.find( + product => product.id === productId && product.priceId === priceId + ); + if (!item) return; + addItemToCart(item, 1); + }); + } + + for (const button of ui.cartList.querySelectorAll('.js-cart-minus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const index = state.cart.findIndex( + entry => entry.id === productId && entry.priceId === priceId + ); + if (index < 0) return; + state.cart[index].quantity -= 1; + if (state.cart[index].quantity <= 0) { + const removed = state.cart.splice(index, 1)[0]; + if (removed) + writeLog(`cart_remove: ${removed.id} qty=${removed.quantity}`); + } + syncCartState(); + }); + } +} + +function renderCatalog() { + if (state.catalog.length === 0) { + ui.catalogGrid.innerHTML = + '
No catalog items found.
'; + return; + } + + const cards = state.catalog + .map(item => { + const merch = getMerchandising(item); + const unitPrice = parseAmountFromPriceLabel(item.priceLabel); + const compareAt = + merch?.compareAt && merch.compareAt > unitPrice + ? merch.compareAt + : null; + const purchaseType = + item.mode === 'subscription' ? 'Subscription' : 'One-time'; + const rating = merch?.rating ?? 4.7; + const reviews = merch?.reviews ?? 100; + const stars = '★★★★★'; + const inCartQty = getCartQuantity(item.id, item.priceId); + const cartKey = getCartKey(item.id, item.priceId); + const stepperClass = + state.animatedStepperKey === cartKey + ? 'card-stepper bump' + : 'card-stepper'; + const isMissingPrice = !item.priceId; + const itemId = escapeHtml(item.id); + const itemName = escapeHtml(item.name); + const itemDescription = escapeHtml(item.description); + const priceId = escapeHtml(item.priceId); + const priceLabel = escapeHtml(item.priceLabel); + const badge = merch?.badge ? escapeHtml(merch.badge) : ''; + return ` +
+
+ +
+
+ ${badge ? `${badge}` : ''} + ${purchaseType} +
+
+

${itemName}

+

${itemDescription}

+
+
${rating.toFixed(1)}${stars}(${reviews})
+
+
+
${priceLabel}
+ ${compareAt ? `
List: ${formatUsd(compareAt)}
` : ''} +
+
+
+ ${ + inCartQty > 0 + ? ` +
+ +
${inCartQty} in cart
+ +
+ ` + : isMissingPrice + ? '' + : `` + } +
+
+
+
+ `; + }) + .join(''); + + ui.catalogGrid.innerHTML = cards; + for (const button of ui.catalogGrid.querySelectorAll('.js-add-to-cart')) { + button.addEventListener('click', async () => { + const productId = button.getAttribute('data-product-id') || ''; + const item = state.catalog.find(product => product.id === productId); + if (!item) return; + setError(''); + addItemToCart(item, 1); + await validatePriceForItem(item).catch(error => setError(error.message)); + }); + } + + for (const button of ui.catalogGrid.querySelectorAll('.js-card-plus')) { + button.addEventListener('click', async () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const item = state.catalog.find( + product => product.id === productId && product.priceId === priceId + ); + if (!item) return; + setError(''); + addItemToCart(item, 1); + await validatePriceForItem(item).catch(error => setError(error.message)); + }); + } + + for (const button of ui.catalogGrid.querySelectorAll('.js-card-minus')) { + button.addEventListener('click', () => { + const productId = button.getAttribute('data-product-id') || ''; + const priceId = button.getAttribute('data-price-id') || ''; + const index = state.cart.findIndex( + entry => entry.id === productId && entry.priceId === priceId + ); + if (index < 0) return; + state.cart[index].quantity -= 1; + if (state.cart[index].quantity <= 0) { + const removed = state.cart.splice(index, 1)[0]; + if (removed) + writeLog(`cart_remove: ${removed.id} qty=${removed.quantity}`); + } + syncCartState(); + }); + } +} + +function renderCatalogSkeleton(count = 6) { + const cards = Array.from( + { length: count }, + () => ` + + ` + ).join(''); + ui.catalogGrid.innerHTML = cards; +} + +window.addEventListener('stdb:catalog', event => { + const products = event.detail?.products ?? []; + const sorted = [...products].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) + ); + state.catalog = sorted; + const catalogById = new Map(sorted.map(item => [item.id, item])); + const previousCartCount = state.cart.length; + state.cart = state.cart + .filter(entry => { + const item = catalogById.get(entry.id); + return item && item.priceId && item.priceId === entry.priceId; + }) + .map(entry => { + const item = catalogById.get(entry.id); + return { + ...entry, + name: item.name, + priceLabel: item.priceLabel, + mode: item.mode, + }; + }); + if (state.cart.length !== previousCartCount) { + setError( + 'Your saved cart contained outdated Stripe prices and was refreshed.' + ); + } + syncCartState(); +}); + +window.addEventListener('stdb:connState', event => { + const detail = event.detail || {}; + if (detail.state === 'connected') { + writeLog('stdb: connected'); + return; + } + if (detail.state === 'error') { + const message = detail.detail || 'SpacetimeDB connection failed.'; + setError(message); + writeLog(`stdb: error: ${message}`); + return; + } + writeLog('stdb: connecting'); +}); + +async function validatePriceForItem(item) { + if (!item?.priceId) { + writeLog('price validation: missing price id'); + return; + } + if (!window.stdb) { + writeLog('price validation: STDB not connected yet'); + return; + } + const validation = await window.stdb.validatePrice(item.priceId); + if (validation.valid) { + writeLog(`price valid: ${item.priceId} (active=${validation.active})`); + } else { + writeLog(`price invalid: ${validation.message || 'not found'}`); + } +} + +async function getOrCreateCustomer() { + setError(''); + if (!window.stdb) { + throw new Error('STDB not connected yet. Try again.'); + } + const result = await window.stdb.getOrCreateCustomer({ + userId: ui.userId.value, + email: ui.email.value || undefined, + name: ui.name.value || undefined, + }); + if (result.customerId) ui.customerId.value = result.customerId; + writeLog(`get_or_create_customer: ${JSON.stringify(result)}`); +} + +async function createCheckoutForCart(cartItems, triggerButton) { + if (!cartItems || cartItems.length === 0) throw new Error('Cart is empty.'); + const missingPrice = cartItems.find(entry => !entry.priceId); + if (missingPrice) { + throw new Error(missingPriceMessage(missingPrice.name)); + } + + const modes = Array.from(new Set(cartItems.map(entry => entry.mode))); + if (modes.length > 1) { + throw new Error( + 'Stripe checkout cannot mix one-time and subscription items in the same session. ' + + 'Please remove one type and check out separately.' + ); + } + const mode = modes[0]; + + const successUrl = buildPostCheckoutUrl('purchased'); + const cancelUrl = buildPostCheckoutUrl('canceled'); + + state.checkoutPending = true; + syncCartState(); + setButtonLoading(triggerButton, true, 'Redirecting...'); + + try { + if (!window.stdb) { + throw new Error('STDB not connected yet. Try again.'); + } + const result = await window.stdb.createCheckoutSession({ + items: cartItems.map(entry => ({ + priceId: entry.priceId, + quantity: entry.quantity, + })), + customerId: ui.customerId.value || undefined, + mode, + successUrl, + cancelUrl, + }); + writeLog(`create_checkout_session: ${JSON.stringify(result)}`); + + if (!result.url) { + throw new Error('Stripe checkout URL missing from session response.'); + } + window.location.assign(result.url); + } finally { + setButtonLoading(triggerButton, false); + state.checkoutPending = false; + syncCartState(); + } +} + +function addItemToCart(item, quantity) { + if (!item) return; + if (!item.priceId) { + const message = missingPriceMessage(item.name); + setError(message); + writeLog(message); + return; + } + state.animatedStepperKey = getCartKey(item.id, item.priceId); + const existing = state.cart.find( + entry => entry.id === item.id && entry.priceId === item.priceId + ); + if (existing) { + existing.quantity += quantity; + } else { + state.cart.push({ + id: item.id, + name: item.name, + priceLabel: item.priceLabel, + mode: item.mode, + priceId: item.priceId, + quantity, + }); + } + syncCartState(); + writeLog(`cart_add: ${item.id} qty=${quantity}`); + setTimeout(() => { + if (state.animatedStepperKey === getCartKey(item.id, item.priceId)) { + state.animatedStepperKey = null; + if (state.catalog.length > 0) renderCatalog(); + } + }, 260); +} + +async function createCheckoutCartNext() { + setError(''); + if (state.cart.length === 0) throw new Error('Cart is empty.'); + for (const entry of state.cart) { + const item = state.catalog.find(product => product.id === entry.id); + if (!item) + throw new Error(`Cart item ${entry.id} is absent from the catalog.`); + } + await createCheckoutForCart(state.cart, ui.btnCheckoutCart); +} + +function wireEvents() { + if (ui.btnCart) ui.btnCart.addEventListener('click', openCart); + ui.btnCartClose.addEventListener('click', closeCart); + ui.btnContinueShopping.addEventListener('click', closeCart); + ui.cartBackdrop.addEventListener('click', closeCart); + + if (ui.btnSettings) { + ui.btnSettings.addEventListener('click', openDevTools); + } + + ui.btnCustomer.addEventListener('click', () => { + getOrCreateCustomer().catch(error => setError(error.message)); + }); + + ui.btnCheckoutCart.addEventListener('click', () => { + createCheckoutCartNext().catch(error => setError(error.message)); + }); + + ui.btnDevToolsClose.addEventListener('click', closeDevTools); + ui.devToolsBackdrop.addEventListener('click', closeDevTools); + + if (ui.checkoutBannerClose) { + ui.checkoutBannerClose.addEventListener('click', hideCheckoutBanner); + } + + document.addEventListener('keydown', event => { + const key = String(event.key || '').toLowerCase(); + if ((event.ctrlKey || event.metaKey) && event.shiftKey && key === 'd') { + event.preventDefault(); + openDevTools(); + return; + } + if (event.key === 'Escape' && !ui.cartPopout.hidden) { + closeCart(); + return; + } + if (event.key === 'Escape' && !ui.devToolsPopout.hidden) { + closeDevTools(); + } + }); +} + +async function boot() { + renderCatalogSkeleton(); + await api('/api/config'); + seedDefaultBuyerDetails(); + hydrateCart(); + wireEvents(); + consumePostCheckoutQueryFlags(); + syncCartState(); + if (state.catalog.length > 0) renderCatalog(); +} + +boot().catch(error => { + setError(error.message); + writeLog(`boot error: ${error.message}`); +}); diff --git a/spacetime-stripe-ts/example/server.ts b/spacetime-stripe-ts/example/server.ts new file mode 100644 index 00000000000..83dbdd58714 --- /dev/null +++ b/spacetime-stripe-ts/example/server.ts @@ -0,0 +1,419 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { + discardStoredServerToken, + exampleUiAssetsDir, + grantServerIdentity, + loadServerToken, + saveServerToken, +} from '@spacetimedb/submodule-shared/server'; +import { + DbConnection, + tables, + type ErrorContext, +} from './src/module_bindings/app'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// App .env wins. Falls back to spacetime-stripe-ts/.env for package-level defaults. +dotenv.config({ path: path.resolve(__dirname, '.env') }); +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); + +const PORT = Number.parseInt(process.env.PORT ?? '8787', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-stripe-example'; +const NODE_ENV = (process.env.NODE_ENV ?? '').replace(/^['"]|['"]$/g, ''); +const IS_PRODUCTION = NODE_ENV === 'production'; +const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; +const SYNC_PRICES_ON_START = process.env.STRIPE_SYNC_PRICES === '1'; +const IS_LOOPBACK_HOST = + HOST === '127.0.0.1' || HOST === 'localhost' || HOST === '::1'; +const ALLOW_BROWSER_PROVIDER_ACTIONS = + (!IS_PRODUCTION && IS_LOOPBACK_HOST) || + process.env.STRIPE_ALLOW_BROWSER_PROVIDER_ACTIONS === '1'; +const DEFAULT_RETURN_HOST = + HOST === '0.0.0.0' || HOST === '::' ? '127.0.0.1' : HOST; +const STRIPE_RETURN_BASE_URL = + process.env.STRIPE_RETURN_BASE_URL?.trim() || + `http://${DEFAULT_RETURN_HOST}:${PORT}`; +const SERVER_TOKEN_PATH = path.resolve(__dirname, '.stdb-server-token'); + +let stdb: DbConnection | null = null; +let stripeConfigured = false; + +type SyncPriceResult = { + productId: string; + priceId: string; + action: 'created' | 'linked' | 'kept'; +}; + +type ConnectedServer = { + connection: DbConnection; + identity: string; +}; + +class RequestError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requiredString( + value: unknown, + field: string, + maxLength: number +): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength + ) { + throw new RequestError(400, `invalid_${field}`); + } + return value; +} + +function optionalString( + value: unknown, + field: string, + maxLength: number +): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + return requiredString(value, field, maxLength); +} + +function providerActionsReady(): void { + if (!ALLOW_BROWSER_PROVIDER_ACTIONS) { + throw new RequestError(403, 'browser_provider_actions_disabled'); + } + if (!stripeConfigured) throw new RequestError(503, 'stripe_not_configured'); +} + +function sendRouteError(route: string, error: unknown, res: Response): void { + if (error instanceof RequestError) { + res.status(error.status).json({ error: error.message }); + return; + } + console.error( + `[stripe] ${route} failed: ${error instanceof Error ? error.message : String(error)}` + ); + res.status(502).json({ error: `${route}_failed` }); +} + +function returnUrl(flag: 'purchased' | 'canceled'): string { + const url = new URL('/', STRIPE_RETURN_BASE_URL); + if (IS_PRODUCTION && url.protocol !== 'https:') { + throw new RequestError(500, 'stripe_return_url_requires_https'); + } + url.searchParams.set(flag, '1'); + return url.toString(); +} + +function connectAttempt(token: string | undefined): Promise { + return new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(STDB_URI) + .withDatabaseName(DB_NAME) + .onConnect((connection, identity, nextToken) => { + if (!process.env.STDB_SERVER_TOKEN?.trim()) { + saveServerToken(SERVER_TOKEN_PATH, nextToken); + } + resolve({ connection, identity: identity.toHexString() }); + }) + .onDisconnect((_ctx, err) => { + console.error( + `[stdb] disconnected: ${err?.message ?? 'unknown'}. exiting` + ); + process.exit(1); + }) + .onConnectError((_ctx: ErrorContext, err) => reject(err)); + if (token) builder = builder.withToken(token); + builder.build(); + }); +} + +async function connect(): Promise { + const stored = loadServerToken( + SERVER_TOKEN_PATH, + process.env.STDB_SERVER_TOKEN + ); + try { + return await connectAttempt(stored.token); + } catch (error) { + if (stored.source !== 'file') throw error; + discardStoredServerToken(SERVER_TOKEN_PATH); + console.warn( + '[stdb] stored server token was rejected; creating a new identity' + ); + return connectAttempt(undefined); + } +} + +function requireStdb(): DbConnection { + if (!stdb) throw new Error('STDB not connected yet'); + return stdb; +} + +async function syncStripePricesFromCatalog(): Promise { + const raw = await requireStdb().procedures.syncStoreProductsWithStripe({}); + return JSON.parse(raw) as SyncPriceResult[]; +} + +async function configureStripeFromEnv(): Promise< + 'configured' | 'already-configured' +> { + const secretKey = process.env.STRIPE_SECRET_KEY?.trim(); + if (!secretKey) { + throw new Error( + 'STRIPE_SECRET_KEY is not set in spacetime-stripe-ts/example/.env.' + ); + } + + await requireStdb().procedures.configureStripe({ + secretKey, + stripeVersion: process.env.STRIPE_VERSION || undefined, + webhookSigningSecret: process.env.STRIPE_WEBHOOK_SECRET || undefined, + }); + stripeConfigured = true; + return 'configured'; +} + +const app = express(); +app.use(express.json({ limit: '512kb' })); +app.use('/assets', express.static(exampleUiAssetsDir, staticOptions())); +app.use(express.static(path.join(__dirname, 'public'), staticOptions())); + +function staticOptions() { + if (IS_PRODUCTION) return {}; + return { + etag: false, + maxAge: 0, + setHeaders: (res: Response) => { + res.setHeader('Cache-Control', 'no-store'); + }, + }; +} + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + const envStripeSecret = process.env.STRIPE_SECRET_KEY?.trim() ?? ''; + res.json({ + spacetimeUri: STDB_URI, + databaseName: DB_NAME, + hasStripeSecretKey: envStripeSecret.length > 0, + stripeConfigured, + adminEndpointsEnabled: false, + browserProviderActionsEnabled: ALLOW_BROWSER_PROVIDER_ACTIONS, + syncPricesOnStart: SYNC_PRICES_ON_START, + }); +}); + +app.post('/api/customer', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body)) throw new RequestError(400, 'invalid_body'); + const userId = requiredString(req.body.userId, 'user_id', 128); + const email = optionalString(req.body.email, 'email', 320); + const name = optionalString(req.body.name, 'name', 200); + const result = await requireStdb().procedures.getOrCreateStoreCustomer({ + userId, + email, + name, + }); + res.json(result); + } catch (error) { + sendRouteError('customer', error, res); + } +}); + +app.post('/api/checkout', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body) || !Array.isArray(req.body.items)) { + throw new RequestError(400, 'invalid_body'); + } + if (req.body.items.length === 0 || req.body.items.length > 20) { + throw new RequestError(400, 'invalid_items'); + } + + const mode = requiredString(req.body.mode, 'mode', 32); + if (mode !== 'payment' && mode !== 'subscription') { + throw new RequestError(400, 'invalid_mode'); + } + const customerId = optionalString(req.body.customerId, 'customer_id', 255); + if (customerId && !/^cus_[A-Za-z0-9]+$/.test(customerId)) { + throw new RequestError(400, 'invalid_customer_id'); + } + + const catalog = [...requireStdb().db.storeProduct.iter()]; + const items = req.body.items.map((value, index) => { + if (!isRecord(value)) + throw new RequestError(400, `invalid_item_${index}`); + const priceId = requiredString(value.priceId, `price_id_${index}`, 255); + const quantity = value.quantity; + if ( + typeof quantity !== 'number' || + !Number.isSafeInteger(quantity) || + quantity < 1 || + quantity > 99 + ) { + throw new RequestError(400, `invalid_quantity_${index}`); + } + const product = catalog.find( + row => row.active && row.stripePriceId === priceId && row.mode === mode + ); + if (!product) + throw new RequestError(400, `price_not_in_active_catalog_${index}`); + return { priceId, quantity: BigInt(quantity) }; + }); + + const result = await requireStdb().procedures.createStoreCheckoutSession({ + items, + customerId, + mode, + successUrl: returnUrl('purchased'), + cancelUrl: returnUrl('canceled'), + metadataJson: undefined, + subscriptionMetadataJson: undefined, + paymentIntentMetadataJson: undefined, + }); + res.json(result); + } catch (error) { + sendRouteError('checkout', error, res); + } +}); + +app.post('/api/validate-price', async (req: Request, res: Response) => { + try { + providerActionsReady(); + if (!isRecord(req.body)) throw new RequestError(400, 'invalid_body'); + const priceId = requiredString(req.body.priceId, 'price_id', 255); + const inCatalog = [...requireStdb().db.storeProduct.iter()].some( + row => row.active && row.stripePriceId === priceId + ); + if (!inCatalog) throw new RequestError(400, 'price_not_in_active_catalog'); + const result = await requireStdb().procedures.validateStoreStripePrice({ + priceId, + }); + res.json({ + ...result, + unitAmount: + result.unitAmount === undefined ? undefined : Number(result.unitAmount), + }); + } catch (error) { + sendRouteError('validate_price', error, res); + } +}); + +app.get('/api/webhook-event-count', async (_req: Request, res: Response) => { + try { + providerActionsReady(); + const count = await requireStdb().procedures.getStoreWebhookEventCount({}); + res.json({ count: Number(count) }); + } catch (error) { + sendRouteError('webhook_event_count', error, res); + } +}); + +async function seedCatalogIfEmpty(conn: DbConnection): Promise { + await new Promise((resolve, reject) => { + let resolved = false; + conn + .subscriptionBuilder() + .onApplied(() => { + if (resolved) return; + resolved = true; + resolve(); + }) + .onError((ctx: ErrorContext) => { + if (resolved) return; + resolved = true; + reject(new Error(`catalog probe failed: ${ctx.event}`)); + }) + .subscribe([tables.storeProduct]); + }); + + const count = conn.db.storeProduct.count(); + if (count === 0n) { + await conn.reducers.seedDefaultStoreProducts({ force: undefined }); + console.log('Seeded default store products (catalog was empty).'); + } +} + +(async () => { + console.log(`[stdb] connecting to ${STDB_URI} (database=${DB_NAME}) ...`); + try { + const connected = await connect(); + stdb = connected.connection; + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: DB_NAME, + procedure: 'add_admin_identity', + identity: connected.identity, + }); + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: DB_NAME, + procedure: 'stripe.add_admin_identity', + identity: connected.identity, + }); + console.log(`[stdb] connected as authorized server ${connected.identity}`); + } catch (err) { + console.error( + `[stdb] connection failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[stdb] is the SpacetimeDB host running and the example module published?' + ); + process.exit(1); + } + + try { + await seedCatalogIfEmpty(stdb); + } catch (err) { + console.warn( + `[stdb] could not check/seed catalog: ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (process.env.STRIPE_SECRET_KEY?.trim()) { + try { + await configureStripeFromEnv(); + console.log('[stripe] config loaded from server environment'); + if (SYNC_PRICES_ON_START) { + const results = await syncStripePricesFromCatalog(); + console.log(`[stripe] synchronized ${results.length} catalog prices`); + } + } catch (err) { + console.error( + `[stripe] setup failed: ${err instanceof Error ? err.message : String(err)}` + ); + process.exit(1); + } + } else { + console.warn( + '[stripe] STRIPE_SECRET_KEY is not set; checkout remains unavailable' + ); + } + + app.listen(PORT, HOST, () => { + console.log(`Premium Store example running at http://${HOST}:${PORT}`); + }); +})(); diff --git a/spacetime-stripe-ts/example/spacetimedb/.npmrc b/spacetime-stripe-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-stripe-ts/example/spacetimedb/package.json b/spacetime-stripe-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..b846ac35437 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/package.json @@ -0,0 +1,20 @@ +{ + "name": "spacetime-stripe-example-module", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-stripe-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-stripe-example" + }, + "dependencies": { + "@spacetimedb/stripe": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/index.ts b/spacetime-stripe-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..d2809ffaae0 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,4 @@ +export { default, init } from './store/schema'; +export * from './store/operations'; +export { addAdminIdentity, removeAdminIdentity } from './store/auth'; +export { health, echo, stripeWebhookHandler, router } from './store/webhooks'; diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts new file mode 100644 index 00000000000..3ae11d006c2 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/auth.ts @@ -0,0 +1,79 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './validation'; + +// Admin gate. Fresh publishes seed the owner via init. Public reducers never +// bootstrap admin state from "first caller wins". +type Sender = WriteCtx['sender']; +type ModuleTimestamp = WriteCtx['timestamp']; + +export type AdminVerdict = 'admin' | 'denied'; + +export function isAdmin(ctx: WriteCtx, sender: Sender): boolean { + return ctx.db.storeAdminIdentity.identity.find(sender) != null; +} + +export function adminVerdict(ctx: WriteCtx, sender: Sender): AdminVerdict { + return isAdmin(ctx, sender) ? 'admin' : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throwSenderError('store.not_authorized'); +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('store.not_authorized'); +} + +// For owner-gated repair/setup code only. Do not call from a public bootstrap path. +export function seedAdmin( + ctx: WriteCtx, + sender: Sender, + timestamp: ModuleTimestamp +) { + if (ctx.db.storeAdminIdentity.identity.find(sender) != null) return; + ctx.db.storeAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} + +export const addAdminIdentity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + if (tx.db.storeAdminIdentity.identity.find(identity) == null) { + tx.db.storeAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const removeAdminIdentity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + const existing = tx.db.storeAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.storeAdminIdentity.count() <= 1n) { + throwSenderError('store.cannot_remove_last_admin'); + } + tx.db.storeAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts new file mode 100644 index 00000000000..090f5f63939 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/operations.ts @@ -0,0 +1,585 @@ +import { + Range, + spacetimedb, + t, + type ModuleTimestamp, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import * as stripe from '@spacetimedb/stripe/submodule'; +import { requireAdmin } from './auth'; +import { stringArrayFromJson, throwSenderError } from './validation'; + +const DEFAULT_STORE_PRODUCTS: Array<{ + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + perks: string[]; + sortOrder: bigint; +}> = [ + { + productId: 'orbital-starter-pack', + name: 'Orbital Starter Pack', + description: 'One-time booster crate for first-wave pilots.', + mode: 'payment', + priceLabel: '$19.00', + perks: [ + 'Orbital pilot badge', + 'Nebula ship skin', + 'Priority support queue', + ], + sortOrder: 10n, + }, + { + productId: 'warp-pass', + name: 'Warp Pass', + description: 'Monthly command-tier subscription.', + mode: 'subscription', + priceLabel: '$9.00 / month', + perks: [ + 'Expanded cargo slots', + 'Telemetry dashboard', + 'Beta sector access', + ], + sortOrder: 20n, + }, + { + productId: 'fleet-command-bundle', + name: 'Fleet Command Bundle', + description: 'Multi-seat bundle for your squad.', + mode: 'payment', + priceLabel: '$49.00', + perks: [ + '5 pilot bundle', + 'Faction banner cosmetic', + 'Shared vault upgrade', + ], + sortOrder: 30n, + }, +]; + +const stripeHttpResponse = t.object('StoreStripeHttpResponse', { + status: t.u16(), + body: t.string(), +}); + +const storeValidateStripePriceResult = t.object( + 'StoreValidateStripePriceResult', + { + valid: t.bool(), + status: t.u16(), + active: t.option(t.bool()), + currency: t.option(t.string()), + unitAmount: t.option(t.i64()), + livemode: t.option(t.bool()), + type: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + } +); + +const storeGetOrCreateCustomerResult = t.object( + 'StoreGetOrCreateCustomerResult', + { + customerId: t.string(), + isNew: t.bool(), + } +); + +const storeCheckoutLineItem = t.object('StoreCheckoutLineItem', { + priceId: t.string(), + quantity: t.i64(), +}); + +const storeCheckoutSessionResult = t.object('StoreCheckoutSessionResult', { + sessionId: t.string(), + url: t.option(t.string()), +}); + +function parseAmountCents(priceLabel: string): bigint { + const match = /(\d+)(?:\.(\d{1,2}))?/.exec(priceLabel); + if (!match) { + throwSenderError(`store.invalid_price_label:${priceLabel}`); + } + const whole = BigInt(Number.parseInt(match[1], 10)); + const fraction = BigInt( + match[2] ? Number.parseInt(match[2].padEnd(2, '0'), 10) : 0 + ); + return whole * 100n + fraction; +} + +function getLookupKey(databaseIdentity: string, productId: string): string { + const safeDb = databaseIdentity.replace(/[^A-Za-z0-9_-]/g, '_'); + const safeProduct = productId.replace(/[^A-Za-z0-9_-]/g, '_'); + return `stdb_${safeDb}_${safeProduct}`; +} + +function encodeFormField(value: string): string { + return encodeURIComponent(value).replace(/%20/g, '+'); +} + +function formBody(pairs: Array<[string, string | undefined]>): string { + return pairs + .filter((pair): pair is [string, string] => pair[1] !== undefined) + .map(([key, value]) => `${encodeFormField(key)}=${encodeFormField(value)}`) + .join('&'); +} + +function callStripe( + ctx: ProcedureModuleCtx, + method: string, + path: string, + body?: string +) { + return stripe.stripeApiRequest(ctx.as.stripe, { + method, + path, + formBody: body, + idempotencyKey: undefined, + }) as { status: number; body: string }; +} + +type StripeObject = Record; + +function isObject(value: unknown): value is StripeObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseStripeResponse(response: { + status: number; + body: string; +}): StripeObject { + let parsed: unknown; + try { + parsed = JSON.parse(response.body || '{}'); + } catch { + throwSenderError(`store.stripe_invalid_json:${response.status}`); + } + if (!isObject(parsed)) + throwSenderError(`store.stripe_invalid_response:${response.status}`); + if (response.status < 200 || response.status >= 300) { + const error = isObject(parsed.error) ? parsed.error : undefined; + const message = + typeof error?.message === 'string' + ? error.message + : `Stripe API returned ${response.status}`; + throwSenderError(`store.stripe_api_error:${message}`); + } + return parsed; +} + +function stripeObjectId(value: StripeObject, kind: string): string { + if (typeof value.id !== 'string' || value.id.length === 0) { + throwSenderError(`store.stripe_invalid_${kind}`); + } + return value.id; +} + +function findStripePriceByLookupKey( + ctx: ProcedureModuleCtx, + lookupKey: string +): StripeObject | undefined { + const query = formBody([ + ['lookup_keys[]', lookupKey], + ['active', 'true'], + ['limit', '1'], + ]); + const parsed = parseStripeResponse( + callStripe(ctx, 'GET', `/v1/prices?${query}`) + ); + if (!Array.isArray(parsed.data)) return undefined; + const first = parsed.data[0]; + return isObject(first) ? first : undefined; +} + +function createStripeProduct( + ctx: ProcedureModuleCtx, + args: { + productId: string; + name: string; + description: string; + databaseIdentity: string; + } +) { + return parseStripeResponse( + callStripe( + ctx, + 'POST', + '/v1/products', + formBody([ + ['name', args.name], + ['description', args.description], + ['metadata[stdb_product_id]', args.productId], + ['metadata[stdb_db]', args.databaseIdentity], + ]) + ) + ); +} + +function createStripePrice( + ctx: ProcedureModuleCtx, + args: { + stripeProductId: string; + productId: string; + mode: string; + priceLabel: string; + lookupKey: string; + databaseIdentity: string; + } +) { + const pairs: Array<[string, string | undefined]> = [ + ['product', args.stripeProductId], + ['currency', 'usd'], + ['unit_amount', String(parseAmountCents(args.priceLabel))], + ['lookup_key', args.lookupKey], + ['metadata[stdb_product_id]', args.productId], + ['metadata[stdb_db]', args.databaseIdentity], + ]; + if (args.mode === 'subscription') { + pairs.push(['recurring[interval]', 'month']); + } + return parseStripeResponse( + callStripe(ctx, 'POST', '/v1/prices', formBody(pairs)) + ); +} + +function upsertStoreProductRow( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + stripePriceId: string | undefined; + perksJson: string | undefined; + active: boolean; + sortOrder: bigint; + } +) { + const existing = ctx.db.storeProduct.productId.find(args.productId); + const row = { + productId: args.productId, + name: args.name, + description: args.description, + mode: args.mode, + priceLabel: args.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: args.perksJson, + active: args.active, + sortOrder: args.sortOrder, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.storeProduct.insert(row); + return; + } + ctx.db.storeProduct.productId.update(row); +} + +export const upsertStoreProduct = spacetimedb.reducer( + { + productId: t.string(), + name: t.string(), + description: t.string(), + mode: t.string(), + priceLabel: t.string(), + stripePriceId: t.option(t.string()), + perksJson: t.option(t.string()), + active: t.option(t.bool()), + sortOrder: t.option(t.i64()), + }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + upsertStoreProductRow(tx, ctx.timestamp, { + productId: args.productId, + name: args.name, + description: args.description, + mode: args.mode, + priceLabel: args.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: args.perksJson, + active: args.active ?? true, + sortOrder: args.sortOrder ?? 0n, + }); + } +); + +export const seedDefaultStoreProducts = spacetimedb.reducer( + { force: t.option(t.bool()) }, + (ctx, args) => { + const force = args.force ?? false; + const tx = ctx; + requireAdmin(tx, ctx.sender); + const hasAnyProducts = tx.db.storeProduct.count() > 0n; + if (hasAnyProducts && !force) return; + + for (const product of DEFAULT_STORE_PRODUCTS) { + upsertStoreProductRow(tx, ctx.timestamp, { + productId: product.productId, + name: product.name, + description: product.description, + mode: product.mode, + priceLabel: product.priceLabel, + stripePriceId: undefined, + perksJson: JSON.stringify(product.perks), + active: true, + sortOrder: product.sortOrder, + }); + } + } +); + +export const listStoreProductsJson = spacetimedb.procedure( + {}, + t.string(), + ctx => + ctx.withTx(tx => { + const rows = [ + ...tx.db.storeProduct.byActiveSort.filter([true, new Range()]), + ]; + const mapped = rows.map(row => ({ + id: row.productId, + name: row.name, + description: row.description, + mode: row.mode, + priceLabel: row.priceLabel, + priceId: row.stripePriceId ?? '', + perks: stringArrayFromJson(row.perksJson), + sortOrder: Number(row.sortOrder), + })); + return JSON.stringify(mapped); + }) +); + +export const configureStripe = spacetimedb.procedure( + { + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + const verdict = ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + return true; + }); + void verdict; + return stripe.setStripeConfig(ctx.as.stripe, { + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + webhookSigningSecret: args.webhookSigningSecret, + }); + } +); + +export const storeStripeApiRequest = spacetimedb.procedure( + { + method: t.string(), + path: t.string(), + formBody: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + stripeHttpResponse, + (ctx, args) => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + return stripe.stripeApiRequest(ctx.as.stripe, { + method: args.method, + path: args.path, + formBody: args.formBody, + idempotencyKey: args.idempotencyKey, + }) as { status: number; body: string }; + } +); + +export const validateStoreStripePrice = spacetimedb.procedure( + { priceId: t.string() }, + storeValidateStripePriceResult, + (ctx, args) => + stripe.validateStripePrice(ctx.as.stripe, { + priceId: args.priceId, + }) as { + valid: boolean; + status: number; + active: boolean | undefined; + currency: string | undefined; + unitAmount: bigint | undefined; + livemode: boolean | undefined; + type: string | undefined; + message: string | undefined; + code: string | undefined; + errorType: string | undefined; + } +); + +export const getStoreWebhookEventCount = spacetimedb.procedure( + {}, + t.i64(), + ctx => stripe.getWebhookEventCount(ctx.as.stripe, {}) as bigint +); + +export const getOrCreateStoreCustomer = spacetimedb.procedure( + { + userId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + }, + storeGetOrCreateCustomerResult, + (ctx, args) => + stripe.getOrCreateCustomer(ctx.as.stripe, { + userId: args.userId, + email: args.email, + name: args.name, + }) as { customerId: string; isNew: boolean } +); + +export const createStoreCheckoutSession = spacetimedb.procedure( + { + items: t.array(storeCheckoutLineItem), + customerId: t.option(t.string()), + mode: t.string(), + successUrl: t.string(), + cancelUrl: t.string(), + metadataJson: t.option(t.string()), + subscriptionMetadataJson: t.option(t.string()), + paymentIntentMetadataJson: t.option(t.string()), + }, + storeCheckoutSessionResult, + (ctx, args) => + stripe.createCheckoutSession(ctx.as.stripe, { + items: args.items, + customerId: args.customerId, + mode: args.mode, + successUrl: args.successUrl, + cancelUrl: args.cancelUrl, + metadataJson: args.metadataJson, + subscriptionMetadataJson: args.subscriptionMetadataJson, + paymentIntentMetadataJson: args.paymentIntentMetadataJson, + }) as { sessionId: string; url: string | undefined } +); + +export const syncStoreProductsWithStripe = spacetimedb.procedure( + {}, + t.string(), + ctx => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + const databaseIdentity = ctx.databaseIdentity.toHexString(); + const rows = ctx.withTx(tx => [ + ...tx.db.storeProduct.byActiveSort.filter([true, new Range()]), + ]); + const results: Array<{ + productId: string; + priceId: string; + action: string; + }> = []; + + for (const row of rows) { + if (row.stripePriceId) { + results.push({ + productId: row.productId, + priceId: row.stripePriceId, + action: 'kept', + }); + continue; + } + + const lookupKey = getLookupKey(databaseIdentity, row.productId); + const existing = findStripePriceByLookupKey(ctx, lookupKey); + const price = + existing ?? + (() => { + const product = createStripeProduct(ctx, { + productId: row.productId, + name: row.name, + description: row.description, + databaseIdentity, + }); + return createStripePrice(ctx, { + stripeProductId: stripeObjectId(product, 'product'), + productId: row.productId, + mode: row.mode, + priceLabel: row.priceLabel, + lookupKey, + databaseIdentity, + }); + })(); + + ctx.withTx(tx => { + const current = tx.db.storeProduct.productId.find(row.productId); + if (!current) + throwSenderError(`store.product_not_found:${row.productId}`); + upsertStoreProductRow(tx, ctx.timestamp, { + productId: current.productId, + name: current.name, + description: current.description, + mode: current.mode, + priceLabel: current.priceLabel, + stripePriceId: stripeObjectId(price, 'price'), + perksJson: current.perksJson, + active: current.active, + sortOrder: current.sortOrder, + }); + }); + results.push({ + productId: row.productId, + priceId: stripeObjectId(price, 'price'), + action: existing ? 'linked' : 'created', + }); + } + + return JSON.stringify(results); + } +); + +export const setStoreProductPrice = spacetimedb.reducer( + { productId: t.string(), stripePriceId: t.string() }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + const existing = tx.db.storeProduct.productId.find(args.productId); + if (!existing) { + throwSenderError(`store.product_not_found:${args.productId}`); + } + upsertStoreProductRow(tx, ctx.timestamp, { + productId: existing.productId, + name: existing.name, + description: existing.description, + mode: existing.mode, + priceLabel: existing.priceLabel, + stripePriceId: args.stripePriceId, + perksJson: existing.perksJson, + active: existing.active, + sortOrder: existing.sortOrder, + }); + } +); + +export const clearStoreProductPrice = spacetimedb.reducer( + { productId: t.string() }, + (ctx, args) => { + const tx = ctx; + requireAdmin(tx, ctx.sender); + const existing = tx.db.storeProduct.productId.find(args.productId); + if (!existing) { + throwSenderError(`store.product_not_found:${args.productId}`); + } + upsertStoreProductRow(tx, ctx.timestamp, { + productId: existing.productId, + name: existing.name, + description: existing.description, + mode: existing.mode, + priceLabel: existing.priceLabel, + stripePriceId: undefined, + perksJson: existing.perksJson, + active: existing.active, + sortOrder: existing.sortOrder, + }); + } +); diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/schema.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/schema.ts new file mode 100644 index 00000000000..f185a45e5bc --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/schema.ts @@ -0,0 +1,91 @@ +import { + schema, + table, + t, + Range, + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as stripe from '@spacetimedb/stripe/submodule'; + +export const storeProductRow = { + productId: t.string().primaryKey(), + name: t.string(), + description: t.string(), + mode: t.string(), + priceLabel: t.string(), + stripePriceId: t.option(t.string()), + perksJson: t.option(t.string()), + active: t.bool(), + sortOrder: t.i64(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +// Identities allowed to mutate the catalog. Fresh publishes seed the owner via init. +export const storeAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +export const storeProductTable = table( + { + name: 'store_product', + public: true, + indexes: [ + { + accessor: 'byActiveSort', + algorithm: 'btree', + columns: ['active', 'sortOrder', 'productId'], + }, + { + accessor: 'byModeSort', + algorithm: 'btree', + columns: ['mode', 'sortOrder', 'productId'], + }, + { + accessor: 'byStripePriceId', + algorithm: 'btree', + columns: ['stripePriceId'], + }, + ], + }, + storeProductRow +); + +export const storeAdminIdentityTable = table( + { name: 'store_admin_identity', public: false, indexes: [] }, + storeAdminIdentityRow +); + +export const spacetimedb = schema({ + stripe, + storeProduct: storeProductTable, + storeAdminIdentity: storeAdminIdentityTable, +}); + +export const init = spacetimedb.init(ctx => { + installStore(ctx); + stripe.installStripe(ctx.as.stripe); +}); + +export default spacetimedb; + +export { Range, SenderError, t }; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx< + typeof spacetimedb.schemaType +>; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; +export type ModuleTimestamp = ReducerModuleCtx['timestamp']; + +export function installStore(ctx: ReducerModuleCtx) { + if (ctx.db.storeAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.storeAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/validation.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/validation.ts new file mode 100644 index 00000000000..6dd1c48261c --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/validation.ts @@ -0,0 +1,24 @@ +import { SenderError } from 'spacetimedb/server'; + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function safeJsonParse(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + +export function stringArrayFromJson(value: string | undefined): string[] { + if (!value) return []; + const parsed = safeJsonParse(value); + if (!Array.isArray(parsed)) return []; + const out: string[] = []; + for (const item of parsed) { + if (typeof item === 'string') out.push(item); + } + return out; +} diff --git a/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts b/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts new file mode 100644 index 00000000000..3a697f1b654 --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/src/store/webhooks.ts @@ -0,0 +1,39 @@ +import { Router, SyncResponse } from 'spacetimedb/server'; +import { handleStripeWebhook } from '@spacetimedb/stripe/submodule'; +import { spacetimedb } from './schema'; + +export const stripeWebhookHandler = spacetimedb.httpHandler((ctx, req) => + handleStripeWebhook(ctx.as.stripe, req) +); + +export const health = spacetimedb.httpHandler((ctx, _req) => { + const count = ctx.withTx(tx => tx.db.storeProduct.count()); + return new SyncResponse( + JSON.stringify({ + ok: true, + catalogRows: Number(count), + at: ctx.timestamp.microsSinceUnixEpoch.toString(), + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +}); + +export const echo = spacetimedb.httpHandler((_ctx, req) => { + const body = req.text(); + return new SyncResponse( + JSON.stringify({ + method: req.method, + uri: req.uri, + bodyLength: body.length, + bodyPreview: body.slice(0, 200), + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +}); + +export const router = spacetimedb.httpRouter( + new Router() + .get('/health', health) + .post('/echo', echo) + .post('/stripe/webhook', stripeWebhookHandler) +); diff --git a/spacetime-stripe-ts/example/spacetimedb/tsconfig.json b/spacetime-stripe-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..4b599551afe --- /dev/null +++ b/spacetime-stripe-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-stripe-ts/example/src/app.ts b/spacetime-stripe-ts/example/src/app.ts new file mode 100644 index 00000000000..f3023c24577 --- /dev/null +++ b/spacetime-stripe-ts/example/src/app.ts @@ -0,0 +1,233 @@ +import { + DbConnection, + tables, + type EventContext, + type ErrorContext, +} from './module_bindings/app'; + +declare global { + interface Window { + stdb?: { + getOrCreateCustomer: (args: { + userId: string; + email?: string; + name?: string; + }) => Promise<{ customerId: string; isNew: boolean }>; + createCheckoutSession: (args: { + items: Array<{ priceId: string; quantity: number }>; + customerId?: string; + mode: 'payment' | 'subscription' | string; + successUrl: string; + cancelUrl: string; + metadataJson?: string; + subscriptionMetadataJson?: string; + paymentIntentMetadataJson?: string; + }) => Promise<{ sessionId: string; url?: string }>; + validatePrice: (priceId: string) => Promise<{ + valid: boolean; + active?: boolean; + message?: string; + code?: string; + errorType?: string; + }>; + getWebhookEventCount: () => Promise; + }; + } +} + +type StoreProductRow = { + productId: string; + name: string; + description: string; + mode: string; + priceLabel: string; + stripePriceId: string | undefined; + perksJson: string | undefined; + active: boolean; + sortOrder: bigint; + createdAt: { microsSinceUnixEpoch: bigint }; + updatedAt: { microsSinceUnixEpoch: bigint }; +}; + +interface ServerConfig { + spacetimeUri: string; + databaseName: string; +} + +const products = new Map(); + +function parsePerks(json: string | undefined): string[] { + if (!json) return []; + try { + const parsed = JSON.parse(json); + return Array.isArray(parsed) + ? parsed.filter(x => typeof x === 'string') + : []; + } catch { + return []; + } +} + +function emitCatalog() { + const sorted = [...products.values()] + .filter(p => p.active) + .sort((a, b) => { + const so = Number(a.sortOrder - b.sortOrder); + return so !== 0 ? so : a.productId.localeCompare(b.productId); + }); + window.dispatchEvent( + new CustomEvent('stdb:catalog', { + detail: { + products: sorted.map(p => ({ + id: p.productId, + name: p.name, + description: p.description, + mode: p.mode, + priceLabel: p.priceLabel, + priceId: p.stripePriceId ?? '', + perks: parsePerks(p.perksJson), + sortOrder: Number(p.sortOrder), + active: p.active, + })), + }, + }) + ); +} + +function updateConnState( + state: 'connecting' | 'connected' | 'error', + detail?: string +) { + window.dispatchEvent( + new CustomEvent('stdb:connState', { detail: { state, detail } }) + ); +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +function connect(config: ServerConfig): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => reject(new Error(`Timed out connecting to ${config.spacetimeUri}`)), + 10000 + ); + DbConnection.builder() + .withUri(config.spacetimeUri) + .withDatabaseName(config.databaseName) + .withCompression('none') + .onConnect(c => { + window.clearTimeout(timeout); + resolve(c); + }) + .onDisconnect((_ctx, err) => { + window.clearTimeout(timeout); + updateConnState('error', err?.message ?? 'disconnected'); + }) + .onConnectError((_ctx, err) => { + window.clearTimeout(timeout); + updateConnState('error', 'connect failed (app)'); + reject(err); + }) + .build(); + }); +} + +async function api(path: string, body?: unknown): Promise { + const response = await fetch(path, { + method: body === undefined ? 'GET' : 'POST', + headers: + body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload: unknown = await response.json().catch(() => ({})); + if (!response.ok) { + const message = + isRecord(payload) && typeof payload.error === 'string' + ? payload.error + : `${path} returned ${response.status}`; + throw new Error(message); + } + return payload as T; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function registerRowCallbacks(connection: DbConnection): void { + connection.db.storeProduct.onInsert( + (_ctx: EventContext, row: StoreProductRow) => { + products.set(row.productId, row); + emitCatalog(); + } + ); + connection.db.storeProduct.onUpdate( + (_ctx: EventContext, _oldRow: StoreProductRow, row: StoreProductRow) => { + products.set(row.productId, row); + emitCatalog(); + } + ); + connection.db.storeProduct.onDelete( + (_ctx: EventContext, row: StoreProductRow) => { + products.delete(row.productId); + emitCatalog(); + } + ); +} + +function subscribeToTables(connection: DbConnection): void { + connection + .subscriptionBuilder() + .onApplied(() => { + products.clear(); + for (const row of connection.db.storeProduct.iter() as Iterable) { + products.set(row.productId, row); + } + emitCatalog(); + updateConnState('connected'); + window.dispatchEvent(new CustomEvent('stdb:ready')); + }) + .onError((ctx: ErrorContext) => { + console.error('catalog sub error', ctx.event); + updateConnState('error', String(ctx.event)); + }) + .subscribe([tables.storeProduct]); +} + +async function main() { + updateConnState('connecting'); + let conn: DbConnection; + try { + const config = await loadServerConfig(); + conn = await connect(config); + } catch (err) { + console.error('STDB connect failed:', err); + updateConnState('error', err instanceof Error ? err.message : String(err)); + return; + } + + registerRowCallbacks(conn); + + window.stdb = { + getOrCreateCustomer: args => api('/api/customer', args), + createCheckoutSession: args => + api('/api/checkout', { + items: args.items, + customerId: args.customerId, + mode: args.mode, + }), + validatePrice: priceId => api('/api/validate-price', { priceId }), + getWebhookEventCount: () => + api<{ count: number }>('/api/webhook-event-count').then( + result => result.count + ), + }; + + subscribeToTables(conn); +} + +main(); diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts new file mode 100644 index 00000000000..d564c4111fd --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/clear_store_product_price_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + productId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts new file mode 100644 index 00000000000..e9fc16b27f5 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/configure_stripe_procedure.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts new file mode 100644 index 00000000000..8ea8e71b13d --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/create_store_checkout_session_procedure.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StoreCheckoutLineItem, + StoreCheckoutSessionResult, +} from "./types"; + +export const params = { + get items() { + return __t.array(StoreCheckoutLineItem); + }, + customerId: __t.option(__t.string()), + mode: __t.string(), + successUrl: __t.string(), + cancelUrl: __t.string(), + metadataJson: __t.option(__t.string()), + subscriptionMetadataJson: __t.option(__t.string()), + paymentIntentMetadataJson: __t.option(__t.string()), +}; +export const returnType = StoreCheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts new file mode 100644 index 00000000000..70e89b7767d --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/get_or_create_store_customer_procedure.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StoreGetOrCreateCustomerResult, +} from "./types"; + +export const params = { + userId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), +}; +export const returnType = StoreGetOrCreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts new file mode 100644 index 00000000000..c6724c84a1b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/get_store_webhook_event_count_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.i64() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/index.ts b/spacetime-stripe-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..7ac1bdaa1d3 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,313 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.10.1 (commit 5418e2f229c57f503d4306ae9e5fb6df29533f7d). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import ClearStoreProductPriceReducer from "./clear_store_product_price_reducer"; +import SeedDefaultStoreProductsReducer from "./seed_default_store_products_reducer"; +import SetStoreProductPriceReducer from "./set_store_product_price_reducer"; +import UpsertStoreProductReducer from "./upsert_store_product_reducer"; + +// Import all procedure arg schemas +import * as AddAdminIdentityProcedure from "./add_admin_identity_procedure"; +import * as ConfigureStripeProcedure from "./configure_stripe_procedure"; +import * as CreateStoreCheckoutSessionProcedure from "./create_store_checkout_session_procedure"; +import * as GetOrCreateStoreCustomerProcedure from "./get_or_create_store_customer_procedure"; +import * as GetStoreWebhookEventCountProcedure from "./get_store_webhook_event_count_procedure"; +import * as ListStoreProductsJsonProcedure from "./list_store_products_json_procedure"; +import * as RemoveAdminIdentityProcedure from "./remove_admin_identity_procedure"; +import * as StoreStripeApiRequestProcedure from "./store_stripe_api_request_procedure"; +import * as SyncStoreProductsWithStripeProcedure from "./sync_store_products_with_stripe_procedure"; +import * as ValidateStoreStripePriceProcedure from "./validate_store_stripe_price_procedure"; + +// Import all table schema definitions +import StoreProductRow from "./store_product_table"; + +// Import namespace reducer arg schemas +import Stripe_IngestStripeWebhookReducer from "./stripe/ingest_stripe_webhook_reducer"; +import Stripe_ReplayWebhookEventReducer from "./stripe/replay_webhook_event_reducer"; +import Stripe_UpdatePaymentCustomerReducer from "./stripe/update_payment_customer_reducer"; +import Stripe_UpdateSubscriptionQuantityInternalReducer from "./stripe/update_subscription_quantity_internal_reducer"; +import Stripe_UpsertCustomerReducer from "./stripe/upsert_customer_reducer"; +import Stripe_UpsertSubscriptionReducer from "./stripe/upsert_subscription_reducer"; + +// Import namespace procedure arg schemas +import * as Stripe_AddAdminIdentityProcedure from "./stripe/add_admin_identity_procedure"; +import * as Stripe_CancelSubscriptionProcedure from "./stripe/cancel_subscription_procedure"; +import * as Stripe_CreateCheckoutSessionProcedure from "./stripe/create_checkout_session_procedure"; +import * as Stripe_CreateCustomerProcedure from "./stripe/create_customer_procedure"; +import * as Stripe_CreateCustomerPortalSessionProcedure from "./stripe/create_customer_portal_session_procedure"; +import * as Stripe_CreateOrUpdateCustomerProcedure from "./stripe/create_or_update_customer_procedure"; +import * as Stripe_GetCheckoutSessionProcedure from "./stripe/get_checkout_session_procedure"; +import * as Stripe_GetCustomerProcedure from "./stripe/get_customer_procedure"; +import * as Stripe_GetCustomerByEmailProcedure from "./stripe/get_customer_by_email_procedure"; +import * as Stripe_GetCustomerByUserIdProcedure from "./stripe/get_customer_by_user_id_procedure"; +import * as Stripe_GetOrCreateCustomerProcedure from "./stripe/get_or_create_customer_procedure"; +import * as Stripe_GetPaymentProcedure from "./stripe/get_payment_procedure"; +import * as Stripe_GetRemoteCheckoutSessionProcedure from "./stripe/get_remote_checkout_session_procedure"; +import * as Stripe_GetStripeConfigStatusProcedure from "./stripe/get_stripe_config_status_procedure"; +import * as Stripe_GetSubscriptionProcedure from "./stripe/get_subscription_procedure"; +import * as Stripe_GetSubscriptionByOrgIdProcedure from "./stripe/get_subscription_by_org_id_procedure"; +import * as Stripe_GetWebhookEventCountProcedure from "./stripe/get_webhook_event_count_procedure"; +import * as Stripe_ListCheckoutSessionsProcedure from "./stripe/list_checkout_sessions_procedure"; +import * as Stripe_ListInvoicesProcedure from "./stripe/list_invoices_procedure"; +import * as Stripe_ListInvoicesByOrgIdProcedure from "./stripe/list_invoices_by_org_id_procedure"; +import * as Stripe_ListInvoicesByUserIdProcedure from "./stripe/list_invoices_by_user_id_procedure"; +import * as Stripe_ListPaymentsProcedure from "./stripe/list_payments_procedure"; +import * as Stripe_ListPaymentsByOrgIdProcedure from "./stripe/list_payments_by_org_id_procedure"; +import * as Stripe_ListPaymentsByUserIdProcedure from "./stripe/list_payments_by_user_id_procedure"; +import * as Stripe_ListSubscriptionsProcedure from "./stripe/list_subscriptions_procedure"; +import * as Stripe_ListSubscriptionsByOrgIdProcedure from "./stripe/list_subscriptions_by_org_id_procedure"; +import * as Stripe_ListSubscriptionsByUserIdProcedure from "./stripe/list_subscriptions_by_user_id_procedure"; +import * as Stripe_ListSubscriptionsWithCreationTimeProcedure from "./stripe/list_subscriptions_with_creation_time_procedure"; +import * as Stripe_ReactivateSubscriptionProcedure from "./stripe/reactivate_subscription_procedure"; +import * as Stripe_RemoveAdminIdentityProcedure from "./stripe/remove_admin_identity_procedure"; +import * as Stripe_SetStripeConfigProcedure from "./stripe/set_stripe_config_procedure"; +import * as Stripe_SetStripeWebhookSigningSecretProcedure from "./stripe/set_stripe_webhook_signing_secret_procedure"; +import * as Stripe_StripeApiRequestProcedure from "./stripe/stripe_api_request_procedure"; +import * as Stripe_UpdateSubscriptionMetadataProcedure from "./stripe/update_subscription_metadata_procedure"; +import * as Stripe_UpdateSubscriptionQuantityProcedure from "./stripe/update_subscription_quantity_procedure"; +import * as Stripe_ValidateStripePriceProcedure from "./stripe/validate_stripe_price_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + storeProduct: __table({ + name: 'store_product', + indexes: [ + { accessor: 'byActiveSort', name: 'store_product_active_sort_order_product_id_idx_btree', algorithm: 'btree', columns: [ + 'active', + 'sortOrder', + 'productId', + ] }, + { accessor: 'byModeSort', name: 'store_product_mode_sort_order_product_id_idx_btree', algorithm: 'btree', columns: [ + 'mode', + 'sortOrder', + 'productId', + ] }, + { accessor: 'productId', name: 'store_product_product_id_idx_btree', algorithm: 'btree', columns: [ + 'productId', + ] }, + { accessor: 'byStripePriceId', name: 'store_product_stripe_price_id_idx_btree', algorithm: 'btree', columns: [ + 'stripePriceId', + ] }, + ], + constraints: [ + { name: 'store_product_product_id_key', constraint: 'unique', columns: ['productId'] }, + ], + }, StoreProductRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("clear_store_product_price", ClearStoreProductPriceReducer), + __reducerSchema("seed_default_store_products", SeedDefaultStoreProductsReducer), + __reducerSchema("set_store_product_price", SetStoreProductPriceReducer), + __reducerSchema("upsert_store_product", UpsertStoreProductReducer), + __reducerSchema("stripe.ingest_stripe_webhook", Stripe_IngestStripeWebhookReducer), + __reducerSchema("stripe.replay_webhook_event", Stripe_ReplayWebhookEventReducer), + __reducerSchema("stripe.update_payment_customer", Stripe_UpdatePaymentCustomerReducer), + __reducerSchema("stripe.update_subscription_quantity_internal", Stripe_UpdateSubscriptionQuantityInternalReducer), + __reducerSchema("stripe.upsert_customer", Stripe_UpsertCustomerReducer), + __reducerSchema("stripe.upsert_subscription", Stripe_UpsertSubscriptionReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("add_admin_identity", AddAdminIdentityProcedure.params, AddAdminIdentityProcedure.returnType), + __procedureSchema("configure_stripe", ConfigureStripeProcedure.params, ConfigureStripeProcedure.returnType), + __procedureSchema("create_store_checkout_session", CreateStoreCheckoutSessionProcedure.params, CreateStoreCheckoutSessionProcedure.returnType), + __procedureSchema("get_or_create_store_customer", GetOrCreateStoreCustomerProcedure.params, GetOrCreateStoreCustomerProcedure.returnType), + __procedureSchema("get_store_webhook_event_count", GetStoreWebhookEventCountProcedure.params, GetStoreWebhookEventCountProcedure.returnType), + __procedureSchema("list_store_products_json", ListStoreProductsJsonProcedure.params, ListStoreProductsJsonProcedure.returnType), + __procedureSchema("remove_admin_identity", RemoveAdminIdentityProcedure.params, RemoveAdminIdentityProcedure.returnType), + __procedureSchema("store_stripe_api_request", StoreStripeApiRequestProcedure.params, StoreStripeApiRequestProcedure.returnType), + __procedureSchema("sync_store_products_with_stripe", SyncStoreProductsWithStripeProcedure.params, SyncStoreProductsWithStripeProcedure.returnType), + __procedureSchema("validate_store_stripe_price", ValidateStoreStripePriceProcedure.params, ValidateStoreStripePriceProcedure.returnType), + __procedureSchema("stripe.add_admin_identity", Stripe_AddAdminIdentityProcedure.params, Stripe_AddAdminIdentityProcedure.returnType), + __procedureSchema("stripe.cancel_subscription", Stripe_CancelSubscriptionProcedure.params, Stripe_CancelSubscriptionProcedure.returnType), + __procedureSchema("stripe.create_checkout_session", Stripe_CreateCheckoutSessionProcedure.params, Stripe_CreateCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.create_customer", Stripe_CreateCustomerProcedure.params, Stripe_CreateCustomerProcedure.returnType), + __procedureSchema("stripe.create_customer_portal_session", Stripe_CreateCustomerPortalSessionProcedure.params, Stripe_CreateCustomerPortalSessionProcedure.returnType), + __procedureSchema("stripe.create_or_update_customer", Stripe_CreateOrUpdateCustomerProcedure.params, Stripe_CreateOrUpdateCustomerProcedure.returnType), + __procedureSchema("stripe.get_checkout_session", Stripe_GetCheckoutSessionProcedure.params, Stripe_GetCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.get_customer", Stripe_GetCustomerProcedure.params, Stripe_GetCustomerProcedure.returnType), + __procedureSchema("stripe.get_customer_by_email", Stripe_GetCustomerByEmailProcedure.params, Stripe_GetCustomerByEmailProcedure.returnType), + __procedureSchema("stripe.get_customer_by_user_id", Stripe_GetCustomerByUserIdProcedure.params, Stripe_GetCustomerByUserIdProcedure.returnType), + __procedureSchema("stripe.get_or_create_customer", Stripe_GetOrCreateCustomerProcedure.params, Stripe_GetOrCreateCustomerProcedure.returnType), + __procedureSchema("stripe.get_payment", Stripe_GetPaymentProcedure.params, Stripe_GetPaymentProcedure.returnType), + __procedureSchema("stripe.get_remote_checkout_session", Stripe_GetRemoteCheckoutSessionProcedure.params, Stripe_GetRemoteCheckoutSessionProcedure.returnType), + __procedureSchema("stripe.get_stripe_config_status", Stripe_GetStripeConfigStatusProcedure.params, Stripe_GetStripeConfigStatusProcedure.returnType), + __procedureSchema("stripe.get_subscription", Stripe_GetSubscriptionProcedure.params, Stripe_GetSubscriptionProcedure.returnType), + __procedureSchema("stripe.get_subscription_by_org_id", Stripe_GetSubscriptionByOrgIdProcedure.params, Stripe_GetSubscriptionByOrgIdProcedure.returnType), + __procedureSchema("stripe.get_webhook_event_count", Stripe_GetWebhookEventCountProcedure.params, Stripe_GetWebhookEventCountProcedure.returnType), + __procedureSchema("stripe.list_checkout_sessions", Stripe_ListCheckoutSessionsProcedure.params, Stripe_ListCheckoutSessionsProcedure.returnType), + __procedureSchema("stripe.list_invoices", Stripe_ListInvoicesProcedure.params, Stripe_ListInvoicesProcedure.returnType), + __procedureSchema("stripe.list_invoices_by_org_id", Stripe_ListInvoicesByOrgIdProcedure.params, Stripe_ListInvoicesByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_invoices_by_user_id", Stripe_ListInvoicesByUserIdProcedure.params, Stripe_ListInvoicesByUserIdProcedure.returnType), + __procedureSchema("stripe.list_payments", Stripe_ListPaymentsProcedure.params, Stripe_ListPaymentsProcedure.returnType), + __procedureSchema("stripe.list_payments_by_org_id", Stripe_ListPaymentsByOrgIdProcedure.params, Stripe_ListPaymentsByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_payments_by_user_id", Stripe_ListPaymentsByUserIdProcedure.params, Stripe_ListPaymentsByUserIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions", Stripe_ListSubscriptionsProcedure.params, Stripe_ListSubscriptionsProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_by_org_id", Stripe_ListSubscriptionsByOrgIdProcedure.params, Stripe_ListSubscriptionsByOrgIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_by_user_id", Stripe_ListSubscriptionsByUserIdProcedure.params, Stripe_ListSubscriptionsByUserIdProcedure.returnType), + __procedureSchema("stripe.list_subscriptions_with_creation_time", Stripe_ListSubscriptionsWithCreationTimeProcedure.params, Stripe_ListSubscriptionsWithCreationTimeProcedure.returnType), + __procedureSchema("stripe.reactivate_subscription", Stripe_ReactivateSubscriptionProcedure.params, Stripe_ReactivateSubscriptionProcedure.returnType), + __procedureSchema("stripe.remove_admin_identity", Stripe_RemoveAdminIdentityProcedure.params, Stripe_RemoveAdminIdentityProcedure.returnType), + __procedureSchema("stripe.set_stripe_config", Stripe_SetStripeConfigProcedure.params, Stripe_SetStripeConfigProcedure.returnType), + __procedureSchema("stripe.set_stripe_webhook_signing_secret", Stripe_SetStripeWebhookSigningSecretProcedure.params, Stripe_SetStripeWebhookSigningSecretProcedure.returnType), + __procedureSchema("stripe.stripe_api_request", Stripe_StripeApiRequestProcedure.params, Stripe_StripeApiRequestProcedure.returnType), + __procedureSchema("stripe.update_subscription_metadata", Stripe_UpdateSubscriptionMetadataProcedure.params, Stripe_UpdateSubscriptionMetadataProcedure.returnType), + __procedureSchema("stripe.update_subscription_quantity", Stripe_UpdateSubscriptionQuantityProcedure.params, Stripe_UpdateSubscriptionQuantityProcedure.returnType), + __procedureSchema("stripe.validate_stripe_price", Stripe_ValidateStripePriceProcedure.params, Stripe_ValidateStripePriceProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.10.1" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + clearStoreProductPrice: __reducerAccessors.clearStoreProductPrice, + seedDefaultStoreProducts: __reducerAccessors.seedDefaultStoreProducts, + setStoreProductPrice: __reducerAccessors.setStoreProductPrice, + upsertStoreProduct: __reducerAccessors.upsertStoreProduct, + stripe: { + ingestStripeWebhook: __reducerAccessors["stripe.ingestStripeWebhook"], + replayWebhookEvent: __reducerAccessors["stripe.replayWebhookEvent"], + updatePaymentCustomer: __reducerAccessors["stripe.updatePaymentCustomer"], + updateSubscriptionQuantityInternal: __reducerAccessors["stripe.updateSubscriptionQuantityInternal"], + upsertCustomer: __reducerAccessors["stripe.upsertCustomer"], + upsertSubscription: __reducerAccessors["stripe.upsertSubscription"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + addAdminIdentity: __procedureAccessors.addAdminIdentity, + configureStripe: __procedureAccessors.configureStripe, + createStoreCheckoutSession: __procedureAccessors.createStoreCheckoutSession, + getOrCreateStoreCustomer: __procedureAccessors.getOrCreateStoreCustomer, + getStoreWebhookEventCount: __procedureAccessors.getStoreWebhookEventCount, + listStoreProductsJson: __procedureAccessors.listStoreProductsJson, + removeAdminIdentity: __procedureAccessors.removeAdminIdentity, + storeStripeApiRequest: __procedureAccessors.storeStripeApiRequest, + syncStoreProductsWithStripe: __procedureAccessors.syncStoreProductsWithStripe, + validateStoreStripePrice: __procedureAccessors.validateStoreStripePrice, + stripe: { + addAdminIdentity: __procedureAccessors["stripe.addAdminIdentity"], + cancelSubscription: __procedureAccessors["stripe.cancelSubscription"], + createCheckoutSession: __procedureAccessors["stripe.createCheckoutSession"], + createCustomer: __procedureAccessors["stripe.createCustomer"], + createCustomerPortalSession: __procedureAccessors["stripe.createCustomerPortalSession"], + createOrUpdateCustomer: __procedureAccessors["stripe.createOrUpdateCustomer"], + getCheckoutSession: __procedureAccessors["stripe.getCheckoutSession"], + getCustomer: __procedureAccessors["stripe.getCustomer"], + getCustomerByEmail: __procedureAccessors["stripe.getCustomerByEmail"], + getCustomerByUserId: __procedureAccessors["stripe.getCustomerByUserId"], + getOrCreateCustomer: __procedureAccessors["stripe.getOrCreateCustomer"], + getPayment: __procedureAccessors["stripe.getPayment"], + getRemoteCheckoutSession: __procedureAccessors["stripe.getRemoteCheckoutSession"], + getStripeConfigStatus: __procedureAccessors["stripe.getStripeConfigStatus"], + getSubscription: __procedureAccessors["stripe.getSubscription"], + getSubscriptionByOrgId: __procedureAccessors["stripe.getSubscriptionByOrgId"], + getWebhookEventCount: __procedureAccessors["stripe.getWebhookEventCount"], + listCheckoutSessions: __procedureAccessors["stripe.listCheckoutSessions"], + listInvoices: __procedureAccessors["stripe.listInvoices"], + listInvoicesByOrgId: __procedureAccessors["stripe.listInvoicesByOrgId"], + listInvoicesByUserId: __procedureAccessors["stripe.listInvoicesByUserId"], + listPayments: __procedureAccessors["stripe.listPayments"], + listPaymentsByOrgId: __procedureAccessors["stripe.listPaymentsByOrgId"], + listPaymentsByUserId: __procedureAccessors["stripe.listPaymentsByUserId"], + listSubscriptions: __procedureAccessors["stripe.listSubscriptions"], + listSubscriptionsByOrgId: __procedureAccessors["stripe.listSubscriptionsByOrgId"], + listSubscriptionsByUserId: __procedureAccessors["stripe.listSubscriptionsByUserId"], + listSubscriptionsWithCreationTime: __procedureAccessors["stripe.listSubscriptionsWithCreationTime"], + reactivateSubscription: __procedureAccessors["stripe.reactivateSubscription"], + removeAdminIdentity: __procedureAccessors["stripe.removeAdminIdentity"], + setStripeConfig: __procedureAccessors["stripe.setStripeConfig"], + setStripeWebhookSigningSecret: __procedureAccessors["stripe.setStripeWebhookSigningSecret"], + stripeApiRequest: __procedureAccessors["stripe.stripeApiRequest"], + updateSubscriptionMetadata: __procedureAccessors["stripe.updateSubscriptionMetadata"], + updateSubscriptionQuantity: __procedureAccessors["stripe.updateSubscriptionQuantity"], + validateStripePrice: __procedureAccessors["stripe.validateStripePrice"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/list_store_products_json_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts new file mode 100644 index 00000000000..c338c0c1984 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/seed_default_store_products_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + force: __t.option(__t.bool()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts new file mode 100644 index 00000000000..ed82e1f1eb8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/set_store_product_price_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + productId: __t.string(), + stripePriceId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts b/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts new file mode 100644 index 00000000000..a566792da32 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/store_product_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + productId: __t.string().primaryKey().name("product_id"), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string().name("price_label"), + stripePriceId: __t.option(__t.string()).name("stripe_price_id"), + perksJson: __t.option(__t.string()).name("perks_json"), + active: __t.bool(), + sortOrder: __t.i64().name("sort_order"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts new file mode 100644 index 00000000000..403004799f9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/store_stripe_api_request_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StoreStripeHttpResponse, +} from "./types"; + +export const params = { + method: __t.string(), + path: __t.string(), + formBody: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = StoreStripeHttpResponse \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts new file mode 100644 index 00000000000..d59e794b433 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/cancel_subscription_procedure.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeSubscriptionId: __t.string(), + cancelAtPeriodEnd: __t.option(__t.bool()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts new file mode 100644 index 00000000000..b3e52b631f8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_checkout_session_procedure.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + CheckoutLineItem, + CheckoutSessionResult, +} from "./types"; + +export const params = { + get items() { + return __t.array(CheckoutLineItem); + }, + customerId: __t.option(__t.string()), + mode: __t.string(), + successUrl: __t.string(), + cancelUrl: __t.string(), + metadataJson: __t.option(__t.string()), + subscriptionMetadataJson: __t.option(__t.string()), + paymentIntentMetadataJson: __t.option(__t.string()), +}; +export const returnType = CheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts new file mode 100644 index 00000000000..0fc0f271832 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_portal_session_procedure.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + PortalSessionResult, +} from "./types"; + +export const params = { + customerId: __t.string(), + returnUrl: __t.string(), +}; +export const returnType = PortalSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts new file mode 100644 index 00000000000..ce18664af55 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_customer_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + CreateCustomerResult, +} from "./types"; + +export const params = { + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = CreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts new file mode 100644 index 00000000000..6f40113aa3c --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/create_or_update_customer_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeCustomerId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts new file mode 100644 index 00000000000..7cfe49f0fef --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_checkout_session_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCheckoutSession, +} from "./types"; + +export const params = { + stripeCheckoutSessionId: __t.string(), +}; +export const returnType = __t.option(StripeCheckoutSession) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts new file mode 100644 index 00000000000..c385e289a71 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_email_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + email: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts new file mode 100644 index 00000000000..11e4cd18c67 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_by_user_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts new file mode 100644 index 00000000000..74f769984fa --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_customer_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCustomer, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.option(StripeCustomer) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts new file mode 100644 index 00000000000..1f6ece8f9e7 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_or_create_customer_procedure.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + GetOrCreateCustomerResult, +} from "./types"; + +export const params = { + userId: __t.string(), + email: __t.option(__t.string()), + name: __t.option(__t.string()), +}; +export const returnType = GetOrCreateCustomerResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts new file mode 100644 index 00000000000..4468f783e36 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_payment_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripePayment, +} from "./types"; + +export const params = { + stripePaymentIntentId: __t.string(), +}; +export const returnType = __t.option(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts new file mode 100644 index 00000000000..8b01ada5924 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_remote_checkout_session_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RemoteCheckoutSessionResult, +} from "./types"; + +export const params = { + sessionId: __t.string(), +}; +export const returnType = RemoteCheckoutSessionResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts new file mode 100644 index 00000000000..38405892656 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_stripe_config_status_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeConfigStatus, +} from "./types"; + +export const params = { +}; +export const returnType = StripeConfigStatus \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts new file mode 100644 index 00000000000..ed0cbdf5244 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_by_org_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.option(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts new file mode 100644 index 00000000000..fe90f17b760 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_subscription_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + stripeSubscriptionId: __t.string(), +}; +export const returnType = __t.option(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts new file mode 100644 index 00000000000..c6724c84a1b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/get_webhook_event_count_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.i64() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts new file mode 100644 index 00000000000..e27fe18a79e --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/ingest_stripe_webhook_reducer.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + eventId: __t.string(), + eventType: __t.string(), + livemode: __t.bool(), + payloadJson: __t.string(), + signatureHeader: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts new file mode 100644 index 00000000000..a378145917a --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_checkout_sessions_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeCheckoutSession, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeCheckoutSession) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts new file mode 100644 index 00000000000..e4c68004285 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_org_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts new file mode 100644 index 00000000000..2c263236bc2 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_by_user_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts new file mode 100644 index 00000000000..76a084f415f --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_invoices_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeInvoice, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeInvoice) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts new file mode 100644 index 00000000000..67186316774 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_org_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripePayment, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts new file mode 100644 index 00000000000..e1f5a7244f9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_by_user_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripePayment, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts new file mode 100644 index 00000000000..aa6f5dde97b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_payments_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripePayment, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripePayment) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts new file mode 100644 index 00000000000..0a31e4dc4a0 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_org_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + orgId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts new file mode 100644 index 00000000000..5c71c9018aa --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_by_user_id_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + userId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts new file mode 100644 index 00000000000..cd3f6d99e0a --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeSubscription, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(StripeSubscription) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts new file mode 100644 index 00000000000..01b17ab972b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/list_subscriptions_with_creation_time_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + SubscriptionWithCreationTime, +} from "./types"; + +export const params = { + stripeCustomerId: __t.string(), +}; +export const returnType = __t.array(SubscriptionWithCreationTime) \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts new file mode 100644 index 00000000000..c4f6413f649 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/reactivate_subscription_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeSubscriptionId: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts new file mode 100644 index 00000000000..590a453de82 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/replay_webhook_event_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + eventId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts new file mode 100644 index 00000000000..e9fc16b27f5 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_config_procedure.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts new file mode 100644 index 00000000000..1ea293a0540 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/set_stripe_webhook_signing_secret_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + webhookSigningSecret: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts new file mode 100644 index 00000000000..84d6bbac6f4 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/stripe_api_request_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StripeHttpResponse, +} from "./types"; + +export const params = { + method: __t.string(), + path: __t.string(), + formBody: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; +export const returnType = StripeHttpResponse \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts new file mode 100644 index 00000000000..8ba127bc1c1 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/types.ts @@ -0,0 +1,202 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const CheckoutLineItem = __t.object("CheckoutLineItem", { + priceId: __t.string(), + quantity: __t.i64(), +}); +export type CheckoutLineItem = __Infer; + +export const CheckoutSessionResult = __t.object("CheckoutSessionResult", { + sessionId: __t.string(), + url: __t.option(__t.string()), +}); +export type CheckoutSessionResult = __Infer; + +export const CreateCustomerResult = __t.object("CreateCustomerResult", { + customerId: __t.string(), +}); +export type CreateCustomerResult = __Infer; + +export const GetOrCreateCustomerResult = __t.object("GetOrCreateCustomerResult", { + customerId: __t.string(), + isNew: __t.bool(), +}); +export type GetOrCreateCustomerResult = __Infer; + +export const PortalSessionResult = __t.object("PortalSessionResult", { + url: __t.string(), +}); +export type PortalSessionResult = __Infer; + +export const RemoteCheckoutSessionResult = __t.object("RemoteCheckoutSessionResult", { + ok: __t.bool(), + status: __t.u16(), + sessionId: __t.option(__t.string()), + paymentStatus: __t.option(__t.string()), + sessionStatus: __t.option(__t.string()), + mode: __t.option(__t.string()), + amountTotal: __t.option(__t.i64()), + currency: __t.option(__t.string()), + customerId: __t.option(__t.string()), + paymentIntentId: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type RemoteCheckoutSessionResult = __Infer; + +export const StripeAdminIdentity = __t.object("StripeAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type StripeAdminIdentity = __Infer; + +export const StripeCheckoutSession = __t.object("StripeCheckoutSession", { + stripeCheckoutSessionId: __t.string(), + stripeCustomerId: __t.option(__t.string()), + status: __t.string(), + mode: __t.string(), + metadataJson: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeCheckoutSession = __Infer; + +export const StripeConfig = __t.object("StripeConfig", { + singleton: __t.bool(), + secretKey: __t.string(), + stripeVersion: __t.option(__t.string()), + webhookSigningSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type StripeConfig = __Infer; + +export const StripeConfigStatus = __t.object("StripeConfigStatus", { + isConfigured: __t.bool(), + hasWebhookSecret: __t.bool(), + stripeVersion: __t.option(__t.string()), + secretKeyLength: __t.u16(), +}); +export type StripeConfigStatus = __Infer; + +export const StripeCustomer = __t.object("StripeCustomer", { + stripeCustomerId: __t.string(), + appUserId: __t.option(__t.string()), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + userId: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeCustomer = __Infer; + +export const StripeHttpResponse = __t.object("StripeHttpResponse", { + status: __t.u16(), + body: __t.string(), +}); +export type StripeHttpResponse = __Infer; + +export const StripeInvoice = __t.object("StripeInvoice", { + stripeInvoiceId: __t.string(), + stripeCustomerId: __t.string(), + stripeSubscriptionId: __t.option(__t.string()), + status: __t.string(), + amountDue: __t.i64(), + amountPaid: __t.i64(), + createdUnix: __t.i64(), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeInvoice = __Infer; + +export const StripePayment = __t.object("StripePayment", { + stripePaymentIntentId: __t.string(), + stripeCustomerId: __t.option(__t.string()), + amount: __t.i64(), + currency: __t.string(), + status: __t.string(), + createdUnix: __t.i64(), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripePayment = __Infer; + +export const StripeSubscription = __t.object("StripeSubscription", { + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), + currentPeriodEndUnix: __t.i64(), + cancelAtPeriodEnd: __t.bool(), + cancelAtUnix: __t.option(__t.i64()), + quantity: __t.option(__t.i64()), + priceId: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), + insertedAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StripeSubscription = __Infer; + +export const StripeWebhookEvent = __t.object("StripeWebhookEvent", { + eventId: __t.string(), + eventType: __t.string(), + livemode: __t.bool(), + signatureHeader: __t.option(__t.string()), + payloadJson: __t.string(), + get status() { + return WebhookEventStatus; + }, + errorMessage: __t.option(__t.string()), + receivedAt: __t.timestamp(), + processedAt: __t.option(__t.timestamp()), +}); +export type StripeWebhookEvent = __Infer; + +export const SubscriptionWithCreationTime = __t.object("SubscriptionWithCreationTime", { + insertedAtMicros: __t.i64(), + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), +}); +export type SubscriptionWithCreationTime = __Infer; + +export const ValidateStripePriceResult = __t.object("ValidateStripePriceResult", { + valid: __t.bool(), + status: __t.u16(), + active: __t.option(__t.bool()), + currency: __t.option(__t.string()), + unitAmount: __t.option(__t.i64()), + livemode: __t.option(__t.bool()), + type: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type ValidateStripePriceResult = __Infer; + +// The tagged union or sum type for the algebraic type `WebhookEventStatus`. +export const WebhookEventStatus = __t.enum("WebhookEventStatus", { + Received: __t.unit(), + Processed: __t.unit(), + Ignored: __t.unit(), + Failed: __t.unit(), +}); +export type WebhookEventStatus = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts new file mode 100644 index 00000000000..474aa9ddead --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_payment_customer_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + stripePaymentIntentId: __t.string(), + stripeCustomerId: __t.string(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts new file mode 100644 index 00000000000..519234571e9 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_metadata_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeSubscriptionId: __t.string(), + metadataJson: __t.string(), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts new file mode 100644 index 00000000000..88e2ca53849 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_internal_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + stripeSubscriptionId: __t.string(), + quantity: __t.i64(), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts new file mode 100644 index 00000000000..3db95ceaa68 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/update_subscription_quantity_procedure.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + stripeSubscriptionId: __t.string(), + quantity: __t.i64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts new file mode 100644 index 00000000000..d572ac2d9ac --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_customer_reducer.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + stripeCustomerId: __t.string(), + appUserId: __t.option(__t.string()), + email: __t.option(__t.string()), + name: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts new file mode 100644 index 00000000000..bf4b5c3741b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/upsert_subscription_reducer.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + stripeSubscriptionId: __t.string(), + stripeCustomerId: __t.string(), + status: __t.string(), + currentPeriodEndUnix: __t.i64(), + cancelAtPeriodEnd: __t.bool(), + cancelAtUnix: __t.option(__t.i64()), + quantity: __t.option(__t.i64()), + priceId: __t.option(__t.string()), + metadataJson: __t.option(__t.string()), + orgId: __t.option(__t.string()), + userId: __t.option(__t.string()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts new file mode 100644 index 00000000000..c6e5ab54119 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/stripe/validate_stripe_price_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + ValidateStripePriceResult, +} from "./types"; + +export const params = { + priceId: __t.string(), +}; +export const returnType = ValidateStripePriceResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/sync_store_products_with_stripe_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..c3985719fc6 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,71 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const StoreAdminIdentity = __t.object("StoreAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type StoreAdminIdentity = __Infer; + +export const StoreCheckoutLineItem = __t.object("StoreCheckoutLineItem", { + priceId: __t.string(), + quantity: __t.i64(), +}); +export type StoreCheckoutLineItem = __Infer; + +export const StoreCheckoutSessionResult = __t.object("StoreCheckoutSessionResult", { + sessionId: __t.string(), + url: __t.option(__t.string()), +}); +export type StoreCheckoutSessionResult = __Infer; + +export const StoreGetOrCreateCustomerResult = __t.object("StoreGetOrCreateCustomerResult", { + customerId: __t.string(), + isNew: __t.bool(), +}); +export type StoreGetOrCreateCustomerResult = __Infer; + +export const StoreProduct = __t.object("StoreProduct", { + productId: __t.string(), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string(), + stripePriceId: __t.option(__t.string()), + perksJson: __t.option(__t.string()), + active: __t.bool(), + sortOrder: __t.i64(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type StoreProduct = __Infer; + +export const StoreStripeHttpResponse = __t.object("StoreStripeHttpResponse", { + status: __t.u16(), + body: __t.string(), +}); +export type StoreStripeHttpResponse = __Infer; + +export const StoreValidateStripePriceResult = __t.object("StoreValidateStripePriceResult", { + valid: __t.bool(), + status: __t.u16(), + active: __t.option(__t.bool()), + currency: __t.option(__t.string()), + unitAmount: __t.option(__t.i64()), + livemode: __t.option(__t.bool()), + type: __t.option(__t.string()), + message: __t.option(__t.string()), + code: __t.option(__t.string()), + errorType: __t.option(__t.string()), +}); +export type StoreValidateStripePriceResult = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..d734e416b32 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,40 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as AddAdminIdentityProcedure from "../add_admin_identity_procedure"; +import * as ConfigureStripeProcedure from "../configure_stripe_procedure"; +import * as CreateStoreCheckoutSessionProcedure from "../create_store_checkout_session_procedure"; +import * as GetOrCreateStoreCustomerProcedure from "../get_or_create_store_customer_procedure"; +import * as GetStoreWebhookEventCountProcedure from "../get_store_webhook_event_count_procedure"; +import * as ListStoreProductsJsonProcedure from "../list_store_products_json_procedure"; +import * as RemoveAdminIdentityProcedure from "../remove_admin_identity_procedure"; +import * as StoreStripeApiRequestProcedure from "../store_stripe_api_request_procedure"; +import * as SyncStoreProductsWithStripeProcedure from "../sync_store_products_with_stripe_procedure"; +import * as ValidateStoreStripePriceProcedure from "../validate_store_stripe_price_procedure"; + +export type AddAdminIdentityArgs = __Infer; +export type AddAdminIdentityResult = __Infer; +export type ConfigureStripeArgs = __Infer; +export type ConfigureStripeResult = __Infer; +export type CreateStoreCheckoutSessionArgs = __Infer; +export type CreateStoreCheckoutSessionResult = __Infer; +export type GetOrCreateStoreCustomerArgs = __Infer; +export type GetOrCreateStoreCustomerResult = __Infer; +export type GetStoreWebhookEventCountArgs = __Infer; +export type GetStoreWebhookEventCountResult = __Infer; +export type ListStoreProductsJsonArgs = __Infer; +export type ListStoreProductsJsonResult = __Infer; +export type RemoveAdminIdentityArgs = __Infer; +export type RemoveAdminIdentityResult = __Infer; +export type StoreStripeApiRequestArgs = __Infer; +export type StoreStripeApiRequestResult = __Infer; +export type SyncStoreProductsWithStripeArgs = __Infer; +export type SyncStoreProductsWithStripeResult = __Infer; +export type ValidateStoreStripePriceArgs = __Infer; +export type ValidateStoreStripePriceResult = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..3e4ef971003 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import ClearStoreProductPriceReducer from "../clear_store_product_price_reducer"; +import SeedDefaultStoreProductsReducer from "../seed_default_store_products_reducer"; +import SetStoreProductPriceReducer from "../set_store_product_price_reducer"; +import UpsertStoreProductReducer from "../upsert_store_product_reducer"; + +export type ClearStoreProductPriceParams = __Infer; +export type SeedDefaultStoreProductsParams = __Infer; +export type SetStoreProductPriceParams = __Infer; +export type UpsertStoreProductParams = __Infer; + diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts b/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts new file mode 100644 index 00000000000..fb8d352441e --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/upsert_store_product_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + productId: __t.string(), + name: __t.string(), + description: __t.string(), + mode: __t.string(), + priceLabel: __t.string(), + stripePriceId: __t.option(__t.string()), + perksJson: __t.option(__t.string()), + active: __t.option(__t.bool()), + sortOrder: __t.option(__t.i64()), +}; diff --git a/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts b/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts new file mode 100644 index 00000000000..fe8916221d8 --- /dev/null +++ b/spacetime-stripe-ts/example/src/module_bindings/app/validate_store_stripe_price_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + StoreValidateStripePriceResult, +} from "./types"; + +export const params = { + priceId: __t.string(), +}; +export const returnType = StoreValidateStripePriceResult \ No newline at end of file diff --git a/spacetime-stripe-ts/example/tsconfig.json b/spacetime-stripe-ts/example/tsconfig.json new file mode 100644 index 00000000000..9f5cdd8aac1 --- /dev/null +++ b/spacetime-stripe-ts/example/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*.ts", "server.ts"], + "exclude": ["node_modules", "public", "dist"] +} diff --git a/spacetime-stripe-ts/package.json b/spacetime-stripe-ts/package.json new file mode 100644 index 00000000000..8e29e9bb5c1 --- /dev/null +++ b/spacetime-stripe-ts/package.json @@ -0,0 +1,73 @@ +{ + "name": "@spacetimedb/stripe", + "description": "Stripe catalog, checkout, customer, subscription, and webhook primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-stripe-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-stripe-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "stripe", + "payments", + "webhooks" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test-unit.ts", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-stripe", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-stripe", + "test:smoke": "tsx scripts/test-stripe-smoke.ts", + "test:stripe:e2e": "tsx scripts/test-stripe-e2e.ts", + "test:gate:local": "spacetime build && spacetime publish --server local --yes spacetime-stripe && tsx scripts/test-stripe-e2e.ts --skip-build-publish --skip-checkout-trigger --events customer.created --kill-existing-stripe-listeners" + }, + "dependencies": { + "@spacetimedb/crypto": "workspace:^", + "valibot": "^1.4.2" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "stripe": "^22.1.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-stripe-ts/scripts/test-stripe-e2e.ts b/spacetime-stripe-ts/scripts/test-stripe-e2e.ts new file mode 100644 index 00000000000..4e9540086a1 --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-stripe-e2e.ts @@ -0,0 +1,763 @@ +import * as http from 'node:http'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { existsSync } from 'node:fs'; + +type Options = { + server: string; + database: string; + relayPort: number; + stripeCliPath: string; + stripeProjectName?: string; + stripeApiKey?: string; + events: string[]; + deliveryWaitSeconds: number; + listenerWarmupSeconds: number; + skipBuildPublish: boolean; + skipCheckoutTrigger: boolean; + killExistingStripeListeners: boolean; +}; + +type CmdResult = { + code: number; + stdout: string; + stderr: string; +}; + +type ProcessedRelayEvent = { + eventId: string; + eventType: string; + objectId?: string; +}; + +const DEFAULT_EVENTS = [ + 'customer.created', + 'customer.subscription.created', + 'payment_intent.succeeded', + 'checkout.session.completed', + 'invoice.paid', +]; +const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'; + +function step(name: string) { + process.stdout.write(`\n==> ${name}\n`); +} + +function info(line: string) { + process.stdout.write(` ${line}\n`); +} + +function tailLines(text: string, count: number) { + return text.split(/\r?\n/).filter(Boolean).slice(-count); +} + +function normalizeFlag(flag: string) { + return flag.replace(/^-+/, '').toLowerCase(); +} + +function parseArgs(argv: string[]): Options { + const opts: Options = { + server: 'local', + database: 'stripe-ts-e2e', + relayPort: 12111, + stripeCliPath: 'stripe', + events: [...DEFAULT_EVENTS], + deliveryWaitSeconds: 20, + listenerWarmupSeconds: 3, + skipBuildPublish: false, + skipCheckoutTrigger: false, + killExistingStripeListeners: false, + }; + + const takeValue = (i: number, flag: string) => { + if (i + 1 >= argv.length) { + throw new Error(`Missing value for --${flag}`); + } + return argv[i + 1]!; + }; + + for (let i = 0; i < argv.length; i++) { + const raw = argv[i]!; + if (raw === '--') continue; + if (!raw.startsWith('-')) continue; + const flag = normalizeFlag(raw); + + if (flag === 'help' || flag === 'h') { + process.stdout.write(`Usage: + pnpm run test:stripe:e2e -- [options] + +Options: + --server + --database + --relay-port + --stripe-cli-path + --stripe-project-name + --stripe-api-key + --events + --delivery-wait-seconds + --listener-warmup-seconds + --skip-build-publish + --skip-checkout-trigger + --kill-existing-stripe-listeners +`); + process.exit(0); + } + + if (flag === 'skipbuildpublish' || flag === 'skip-build-publish') { + opts.skipBuildPublish = true; + continue; + } + if (flag === 'skipcheckouttrigger' || flag === 'skip-checkout-trigger') { + opts.skipCheckoutTrigger = true; + continue; + } + if ( + flag === 'killexistingstripelisteners' || + flag === 'kill-existing-stripe-listeners' + ) { + opts.killExistingStripeListeners = true; + continue; + } + + const value = takeValue(i, flag); + i++; + + switch (flag) { + case 'server': + opts.server = value; + break; + case 'database': + opts.database = value; + break; + case 'relayport': + case 'relay-port': + opts.relayPort = Number.parseInt(value, 10); + break; + case 'stripeclipath': + case 'stripe-cli-path': + opts.stripeCliPath = value; + break; + case 'stripeprojectname': + case 'stripe-project-name': + opts.stripeProjectName = value; + break; + case 'stripeapikey': + case 'stripe-api-key': + opts.stripeApiKey = value; + break; + case 'events': + opts.events = value + .split(',') + .map(v => v.trim()) + .filter(Boolean); + break; + case 'deliverywaitseconds': + case 'delivery-wait-seconds': + opts.deliveryWaitSeconds = Number.parseInt(value, 10); + break; + case 'listenerwarmupseconds': + case 'listener-warmup-seconds': + opts.listenerWarmupSeconds = Number.parseInt(value, 10); + break; + default: + throw new Error(`Unknown flag: ${raw}`); + } + } + + if (!Number.isFinite(opts.relayPort) || opts.relayPort <= 0) { + throw new Error('relay port must be a positive integer'); + } + if ( + !Number.isFinite(opts.deliveryWaitSeconds) || + opts.deliveryWaitSeconds <= 0 + ) { + throw new Error('delivery wait seconds must be a positive integer'); + } + if ( + !Number.isFinite(opts.listenerWarmupSeconds) || + opts.listenerWarmupSeconds < 0 + ) { + throw new Error('listener warmup seconds must be >= 0'); + } + if (opts.events.length === 0) { + throw new Error('at least one event is required'); + } + + return opts; +} + +function spawnCapture( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options?.cwd, + env: options?.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' && file.endsWith('.cmd'), + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + stdout += chunk.toString(); + }); + child.stderr.on('data', chunk => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', code => { + resolve({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +function spawnInherit( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: options?.cwd, + env: options?.env, + stdio: 'inherit', + shell: process.platform === 'win32' && file.endsWith('.cmd'), + windowsHide: false, + }); + child.on('error', reject); + child.on('close', code => resolve(code ?? -1)); + }); +} + +async function runChecked( + file: string, + args: string[], + options?: { cwd?: string; env?: NodeJS.ProcessEnv } +) { + info([file, ...args].join(' ')); + const code = await spawnInherit(file, args, options); + if (code !== 0) { + throw new Error(`Command failed with exit code ${code}: ${file}`); + } +} + +async function checkCommand(file: string, args: string[]) { + const out = await spawnCapture(file, args); + if (out.code !== 0) { + throw new Error(`Required command not runnable: ${file}`); + } +} + +async function getWebhookEventCount(server: string, database: string) { + return getSqlCount( + server, + database, + 'select count(*) as c from stripe_webhook_event' + ); +} + +async function getSqlCount(server: string, database: string, query: string) { + const result = await spawnCapture('spacetime', [ + 'sql', + '--server', + server, + database, + query, + ]); + if (result.code !== 0) { + throw new Error( + `Failed SQL query.\n${query}\n${result.stderr || result.stdout}` + ); + } + const merged = `${result.stdout}\n${result.stderr}`; + const lines = merged.split(/\r?\n/); + const numberLine = lines.find(line => /^\s*\d+\s*$/.test(line)); + if (!numberLine) { + throw new Error( + `Could not parse SQL count output.\nQuery: ${query}\n${merged}` + ); + } + return Number.parseInt(numberLine.trim(), 10); +} + +function sqlStringLiteral(value: string) { + return `'${value.replace(/'/g, "''")}'`; +} + +async function assertCountAtLeast( + server: string, + database: string, + query: string, + min: number, + label: string +) { + const count = await getSqlCount(server, database, query); + if (count < min) { + throw new Error( + `Assertion failed: ${label}. Expected >= ${min}, got ${count}. Query: ${query}` + ); + } +} + +async function assertEventWorkflowState( + server: string, + database: string, + event: ProcessedRelayEvent +) { + const eventIdLiteral = sqlStringLiteral(event.eventId); + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_webhook_event where event_id = ${eventIdLiteral}`, + 1, + `webhook event row exists for ${event.eventId}` + ); + + if (!event.objectId) return; + const objectIdLiteral = sqlStringLiteral(event.objectId); + switch (event.eventType) { + case 'customer.created': + case 'customer.updated': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_customer where stripe_customer_id = ${objectIdLiteral}`, + 1, + `customer row exists for ${event.objectId}` + ); + return; + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_subscription where stripe_subscription_id = ${objectIdLiteral}`, + 1, + `subscription row exists for ${event.objectId}` + ); + return; + case 'checkout.session.completed': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_checkout_session where stripe_checkout_session_id = ${objectIdLiteral}`, + 1, + `checkout session row exists for ${event.objectId}` + ); + return; + case 'payment_intent.succeeded': + // Invoice-attached and recent-subscription payment intents are + // ignored to avoid duplicate or orphan payment rows. The + // aggregate assertion in main verifies that at least one standalone + // trigger produced a payment row. + return; + case 'invoice.created': + case 'invoice.finalized': + case 'invoice.paid': + case 'invoice.payment_succeeded': + case 'invoice.payment_failed': + await assertCountAtLeast( + server, + database, + `select count(*) as c from stripe_invoice where stripe_invoice_id = ${objectIdLiteral}`, + 1, + `invoice row exists for ${event.objectId}` + ); + return; + default: + return; + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const moduleRoot = process.cwd(); + const relayOutput: string[] = []; + let stripeListen: ReturnType | undefined; + let stripeStdout = ''; + let stripeStderr = ''; + let relayServer: http.Server | undefined; + let relayHits = 0; + const processedEvents: ProcessedRelayEvent[] = []; + let triggeredEvents: string[] = []; + + try { + step('Check required CLI tools'); + await checkCommand('spacetime', ['--help']); + if (!options.skipBuildPublish) { + await checkCommand(pnpmCommand, ['--version']); + } + if (options.stripeCliPath === 'stripe') { + await checkCommand('stripe', ['version']); + } else if (!existsSync(options.stripeCliPath)) { + throw new Error(`Stripe CLI not found at path: ${options.stripeCliPath}`); + } + + step('Clean stale Stripe listeners'); + if (options.killExistingStripeListeners) { + const kill = await spawnCapture('taskkill', [ + '/F', + '/IM', + 'stripe.exe', + '/T', + ]); + if (kill.code === 0) { + info('stopped existing stripe.exe processes.'); + } else { + info('no existing stripe.exe processes to stop (or no permission).'); + } + } else { + info('skipped (pass --kill-existing-stripe-listeners to force cleanup).'); + } + + if (!options.skipBuildPublish) { + step('Build module'); + await runChecked(pnpmCommand, ['run', 'build'], { cwd: moduleRoot }); + + step('Publish module locally'); + await runChecked( + 'spacetime', + ['publish', '--server', options.server, '--yes', options.database], + { cwd: moduleRoot } + ); + } + + step('Read current webhook event count'); + const beforeCount = await getWebhookEventCount( + options.server, + options.database + ); + const beforePaymentCount = await getSqlCount( + options.server, + options.database, + 'select count(*) as c from stripe_payment' + ); + info(`Current webhook rows: ${beforeCount}`); + + step('Start local relay endpoint'); + relayServer = http.createServer( + async (req: http.IncomingMessage, res: http.ServerResponse) => { + if (req.method !== 'POST' || req.url !== '/stripe-webhook/') { + res.statusCode = 404; + res.end('not found'); + return; + } + + const chunks: Buffer[] = []; + const signatureHeader = req.headers['stripe-signature']; + req.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + await once(req, 'end'); + relayHits++; + + let event: Record; + let rawBody: string; + try { + rawBody = Buffer.concat(chunks).toString('utf8'); + const parsed: unknown = JSON.parse(rawBody); + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('event must be an object'); + } + event = parsed as Record; + } catch (err) { + const msg = `relay error status=400 msg=invalid-json: ${String(err)}`; + relayOutput.push(msg); + res.statusCode = 400; + res.end('invalid json'); + return; + } + + if (typeof event.id !== 'string' || typeof event.type !== 'string') { + const msg = 'relay error status=400 msg=missing-event-id-or-type'; + relayOutput.push(msg); + res.statusCode = 400; + res.end('invalid event'); + return; + } + if (typeof signatureHeader !== 'string' || !signatureHeader) { + relayOutput.push( + `relay event id=${event.id} type=${event.type} status=400 missing-signature` + ); + res.statusCode = 400; + res.end('missing signature'); + return; + } + + const callArgs = [ + 'call', + '--server', + options.server, + options.database, + 'ingest_stripe_webhook', + JSON.stringify(String(event.id)), + JSON.stringify(String(event.type)), + event.livemode === true ? 'true' : 'false', + JSON.stringify(rawBody), + JSON.stringify({ some: signatureHeader }), + ]; + + const ingest = await spawnCapture('spacetime', callArgs); + if (ingest.code !== 0) { + const msg = `relay event id=${event.id} type=${event.type} status=500 ingest-failed`; + relayOutput.push(msg); + relayOutput.push( + tailLines(`${ingest.stdout}\n${ingest.stderr}`, 20).join('\n') + ); + res.statusCode = 500; + res.end('ingest failed'); + return; + } + + processedEvents.push({ + eventId: String(event.id), + eventType: String(event.type), + objectId: (() => { + const data = + typeof event.data === 'object' && event.data !== null + ? (event.data as Record) + : undefined; + const object = + typeof data?.object === 'object' && data.object !== null + ? (data.object as Record) + : undefined; + return typeof object?.id === 'string' ? object.id : undefined; + })(), + }); + const msg = `relay event id=${event.id} type=${event.type} status=200`; + relayOutput.push(msg); + res.statusCode = 200; + res.end('ok'); + } + ); + + await new Promise((resolve, reject) => { + relayServer!.once('error', reject); + relayServer!.listen(options.relayPort, '127.0.0.1', () => resolve()); + }); + info( + `Relay listening on http://127.0.0.1:${options.relayPort}/stripe-webhook/` + ); + + step('Start stripe listener process'); + const eventsCsv = options.events.join(','); + const forwardUrl = `http://127.0.0.1:${options.relayPort}/stripe-webhook/`; + const listenArgs = [ + 'listen', + '--events', + eventsCsv, + '--forward-to', + forwardUrl, + ]; + if (options.stripeProjectName) { + listenArgs.push('--project-name', options.stripeProjectName); + } + if (options.stripeApiKey) { + listenArgs.push('--api-key', options.stripeApiKey); + } + + stripeListen = spawn(options.stripeCliPath, listenArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + windowsHide: true, + }); + if (!stripeListen.stdout || !stripeListen.stderr) { + throw new Error('Failed to capture stripe listen stdout/stderr streams.'); + } + stripeListen.stdout.on('data', chunk => { + stripeStdout += chunk.toString(); + }); + stripeListen.stderr.on('data', chunk => { + stripeStderr += chunk.toString(); + }); + + const readyDeadline = Date.now() + 30_000; + while (Date.now() < readyDeadline) { + if (stripeListen.exitCode !== null) { + throw new Error( + `stripe listen exited early.\nSTDOUT:\n${stripeStdout}\nSTDERR:\n${stripeStderr}` + ); + } + if ( + stripeStdout.includes('Ready!') || + stripeStdout.includes('webhook signing secret') || + stripeStderr.includes('Ready!') || + stripeStderr.includes('webhook signing secret') + ) { + break; + } + await sleep(250); + } + if ( + !stripeStdout.includes('Ready!') && + !stripeStderr.includes('Ready!') && + !stripeStdout.includes('webhook signing secret') && + !stripeStderr.includes('webhook signing secret') + ) { + throw new Error( + `stripe listen did not become ready.\nSTDOUT:\n${stripeStdout}\nSTDERR:\n${stripeStderr}` + ); + } + + info(`stripe listen PID: ${stripeListen.pid ?? 'unknown'}`); + info(`forwarding to: ${forwardUrl}`); + info(`relay port: ${options.relayPort}`); + const listenerOutput = `${stripeStdout}\n${stripeStderr}`; + const signingSecret = listenerOutput.match(/whsec_[A-Za-z0-9]+/)?.[0]; + if (!signingSecret) { + throw new Error( + 'stripe listen became ready without reporting a webhook signing secret' + ); + } + step('Configure the listener webhook secret in the module'); + let configureSecret = await spawnCapture('spacetime', [ + 'call', + '--server', + options.server, + options.database, + 'set_stripe_webhook_signing_secret', + JSON.stringify(signingSecret), + ]); + const configureOutput = `${configureSecret.stdout}\n${configureSecret.stderr}`; + if ( + configureSecret.code !== 0 && + configureOutput.includes('config_not_set') + ) { + configureSecret = await spawnCapture('spacetime', [ + 'call', + '--server', + options.server, + options.database, + 'set_stripe_config', + JSON.stringify('sk_test_e2e_webhook_only'), + 'null', + JSON.stringify({ some: signingSecret }), + ]); + } + if (configureSecret.code !== 0) { + throw new Error( + `could not configure the Stripe listener secret: ${tailLines(`${configureSecret.stdout}\n${configureSecret.stderr}`, 10).join('\n')}` + ); + } + info('listener signing secret configured (value redacted).'); + if (options.listenerWarmupSeconds > 0) { + info(`warming listener for ${options.listenerWarmupSeconds}s...`); + await sleep(options.listenerWarmupSeconds * 1000); + } + + const preflight = await fetch(forwardUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + info(`relay preflight HTTP status: ${preflight.status}`); + + step('Trigger test events through Stripe CLI'); + const eventsToRun = options.skipCheckoutTrigger + ? options.events.filter( + eventName => eventName !== 'checkout.session.completed' + ) + : options.events; + triggeredEvents = [...eventsToRun]; + for (const eventName of eventsToRun) { + const triggerArgs = ['trigger', eventName]; + if (options.stripeProjectName) { + triggerArgs.push('--project-name', options.stripeProjectName); + } + if (options.stripeApiKey) { + triggerArgs.push('--api-key', options.stripeApiKey); + } + await runChecked(options.stripeCliPath, triggerArgs); + await sleep(1000); + } + + step('Validate webhook rows increased'); + if (relayOutput.length > 0) { + process.stdout.write('\n'); + info('relay output (tail):'); + for (const line of relayOutput.slice(-60)) { + info(` ${line}`); + } + } else { + info('relay output: no inbound webhook requests observed.'); + } + + const waitDeadline = Date.now() + options.deliveryWaitSeconds * 1000; + let afterCount = beforeCount; + while (Date.now() < waitDeadline) { + await sleep(1000); + afterCount = await getWebhookEventCount(options.server, options.database); + if (afterCount > beforeCount) break; + } + const delta = afterCount - beforeCount; + info(`Before: ${beforeCount}`); + info(`After: ${afterCount}`); + info(`Delta: ${delta}`); + + if (delta <= 0) { + process.stdout.write('\n'); + info('stripe listen stdout (tail):'); + for (const line of tailLines(stripeStdout, 80)) info(` ${line}`); + process.stdout.write('\n'); + info('stripe listen stderr (tail):'); + for (const line of tailLines(stripeStderr, 80)) info(` ${line}`); + process.stdout.write('\n'); + info(`relay request count: ${relayHits}`); + throw new Error( + 'No new webhook events were ingested. Expected delta > 0.' + ); + } + + for (const eventType of triggeredEvents) { + if (!processedEvents.some(event => event.eventType === eventType)) { + throw new Error( + `Assertion failed: did not observe forwarded event type ${eventType} in relay output.` + ); + } + } + for (const event of processedEvents) { + await assertEventWorkflowState(options.server, options.database, event); + } + if (triggeredEvents.includes('payment_intent.succeeded')) { + const afterPaymentCount = await getSqlCount( + options.server, + options.database, + 'select count(*) as c from stripe_payment' + ); + if (afterPaymentCount <= beforePaymentCount) { + throw new Error( + 'Assertion failed: no standalone payment_intent.succeeded event produced a payment row.' + ); + } + } + info( + `workflow assertions passed for ${processedEvents.length} forwarded event(s).` + ); + + process.stdout.write('\nStripe E2E test passed.\n'); + } finally { + if (stripeListen && stripeListen.exitCode === null && stripeListen.pid) { + try { + await spawnCapture('taskkill', [ + '/PID', + String(stripeListen.pid), + '/T', + '/F', + ]); + } catch { + // Ignore cleanup failures. + } + } + if (relayServer) { + await new Promise(resolve => relayServer!.close(() => resolve())); + } + } +} + +main().catch(err => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); +}); diff --git a/spacetime-stripe-ts/scripts/test-stripe-smoke.ts b/spacetime-stripe-ts/scripts/test-stripe-smoke.ts new file mode 100644 index 00000000000..e1bc02a985e --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-stripe-smoke.ts @@ -0,0 +1,398 @@ +// Synthetic-payload smoke test; locks in webhook behavior without Stripe CLI/keys. + +import { spawn } from 'node:child_process'; +import { createHmac } from 'node:crypto'; + +type Options = { + server: string; + database: string; + skipBuildPublish: boolean; +}; + +function parseArgs(argv: string[]): Options { + const opts: Options = { + server: 'local', + // Dedicated database so the smoke test never overwrites dev module config with placeholders. + database: 'stripe-ts-smoke-test', + skipBuildPublish: false, + }; + for (let i = 0; i < argv.length; i++) { + const raw = argv[i]!; + const flag = raw.replace(/^-+/, '').toLowerCase(); + if (flag === 'skip-build-publish') opts.skipBuildPublish = true; + if (flag === 'server') opts.server = argv[++i]!; + if (flag === 'database') opts.database = argv[++i]!; + } + return opts; +} + +function step(name: string) { + process.stdout.write(`\n==> ${name}\n`); +} + +function run( + cmd: string, + args: string[] +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise(resolve => { + const child = spawn(cmd, args, { shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', d => (stdout += String(d))); + child.stderr?.on('data', d => (stderr += String(d))); + child.on('close', code => resolve({ code: code ?? 1, stdout, stderr })); + child.on('error', err => + resolve({ code: 1, stdout, stderr: stderr + String(err) }) + ); + }); +} + +async function call( + opts: Options, + name: string, + args: string[] +): Promise { + const result = await run('spacetime', [ + 'call', + '--server', + opts.server, + opts.database, + name, + ...args, + ]); + if (result.code !== 0) { + throw new Error( + `spacetime call ${name} failed: code=${result.code}\nstderr: ${result.stderr}\nstdout: ${result.stdout}` + ); + } + return result.stdout; +} + +// Expects the call to fail. Returns combined stderr/stdout for assertion. +async function expectCallFails( + opts: Options, + name: string, + args: string[], + anonymous = false +): Promise { + const result = await run('spacetime', [ + 'call', + ...(anonymous ? ['--anonymous'] : []), + '--server', + opts.server, + opts.database, + name, + ...args, + ]); + if (result.code === 0) { + throw new Error( + `expected ${name} to fail but it succeeded:\nstdout: ${result.stdout}` + ); + } + return result.stderr + result.stdout; +} + +const q = (s: string) => JSON.stringify(s); +const some = (s: string) => JSON.stringify({ some: s }); +const STRIPE_WEBHOOK_SECRET = 'whsec_smoke_test_secret'; + +function stripeSignature(rawBody: string): string { + const ts = Math.floor(Date.now() / 1000); + const digest = createHmac('sha256', STRIPE_WEBHOOK_SECRET) + .update(`${ts}.${rawBody}`) + .digest('hex'); + return `t=${ts},v1=${digest}`; +} + +async function ingest( + opts: Options, + args: { + eventId: string; + eventType: string; + livemode?: boolean; + payload: object; + } +) { + const payloadJson = JSON.stringify(args.payload); + await call(opts, 'ingest_stripe_webhook', [ + q(args.eventId), + q(args.eventType), + String(args.livemode ?? false), + q(payloadJson), + some(stripeSignature(payloadJson)), + ]); +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + if (!opts.skipBuildPublish) { + step('spacetime build'); + const build = await run('spacetime', ['build']); + if (build.code !== 0) { + process.stderr.write(build.stderr); + throw new Error('build failed'); + } + + step(`spacetime publish --server ${opts.server} ${opts.database}`); + const publish = await run('spacetime', [ + 'publish', + '--server', + opts.server, + '--yes', + '--delete-data', + opts.database, + ]); + if (publish.code !== 0) { + process.stderr.write(publish.stderr); + throw new Error('publish failed'); + } + } + + // Procedures needing Stripe secret should refuse cleanly before config. + step('negative: get_or_create_customer before config, expect failure'); + const preBootstrap = await expectCallFails(opts, 'get_or_create_customer', [ + q('u_no_config_yet'), + 'null', + 'null', + ]); + if (!preBootstrap.toLowerCase().includes('config')) { + throw new Error( + `expected error to mention config; got: ${preBootstrap.slice(0, 400)}` + ); + } + + step('set_stripe_config'); + await call(opts, 'set_stripe_config', [ + q('sk_test_smoke_placeholder'), + 'null', + some(STRIPE_WEBHOOK_SECRET), + ]); + + step('negative: anonymous callers cannot read or mutate Stripe state'); + for (const [name, args] of [ + ['get_customer', [q('cus_smoke_1')]], + ['create_customer', ['null', 'null', 'null', 'null']], + [ + 'upsert_customer', + [q('cus_anonymous'), 'null', 'null', 'null', 'null', 'null'], + ], + ] as const) { + const unauthorized = await expectCallFails(opts, name, [...args], true); + if (!unauthorized.toLowerCase().includes('not_authorized')) { + throw new Error( + `expected ${name} to reject a non-admin caller: ${unauthorized.slice(0, 400)}` + ); + } + } + + step('negative: signed webhook metadata must match supplied metadata'); + const mismatchPayload = JSON.stringify({ + id: 'evt_smoke_signed_id', + type: 'customer.created', + data: { object: { id: 'cus_should_not_exist' } }, + }); + const mismatch = await expectCallFails(opts, 'ingest_stripe_webhook', [ + q('evt_smoke_forged_id'), + q('customer.deleted'), + 'false', + q(mismatchPayload), + some(stripeSignature(mismatchPayload)), + ]); + if (!mismatch.toLowerCase().includes('metadata')) { + throw new Error( + `expected signed metadata mismatch failure: ${mismatch.slice(0, 400)}` + ); + } + + step('ingest customer.created, expect customer row'); + await ingest(opts, { + eventId: 'evt_smoke_cust_1', + eventType: 'customer.created', + payload: { + id: 'evt_smoke_cust_1', + type: 'customer.created', + data: { + object: { + id: 'cus_smoke_1', + email: 'smoke@example.com', + name: 'Smoke Test', + metadata: { userId: 'u_smoke_1' }, + }, + }, + }, + }); + const customer = await call(opts, 'get_customer', [q('cus_smoke_1')]); + if (!customer.includes('smoke@example.com')) { + throw new Error( + `customer.created did not produce expected row: ${customer}` + ); + } + + step( + 'ingest customer.subscription.created, expect subscription row + metadata' + ); + await ingest(opts, { + eventId: 'evt_smoke_sub_1', + eventType: 'customer.subscription.created', + payload: { + id: 'evt_smoke_sub_1', + type: 'customer.subscription.created', + data: { + object: { + id: 'sub_smoke_1', + customer: 'cus_smoke_1', + status: 'active', + current_period_end: 1735000000, + cancel_at_period_end: false, + items: { + data: [ + { + current_period_end: 1735000000, + quantity: 1, + price: { id: 'price_smoke_1' }, + }, + ], + }, + metadata: { userId: 'u_smoke_1', orgId: 'o_smoke_1' }, + }, + }, + }, + }); + const sub = await call(opts, 'get_subscription', [q('sub_smoke_1')]); + if (!sub.includes('"active"') || !sub.includes('o_smoke_1')) { + throw new Error( + `subscription.created did not produce expected row: ${sub}` + ); + } + + step('ingest checkout.session.completed, expect session row'); + await ingest(opts, { + eventId: 'evt_smoke_chk_1', + eventType: 'checkout.session.completed', + payload: { + id: 'evt_smoke_chk_1', + type: 'checkout.session.completed', + data: { + object: { + id: 'cs_smoke_1', + mode: 'subscription', + customer: 'cus_smoke_1', + metadata: { userId: 'u_smoke_1' }, + }, + }, + }, + }); + const session = await call(opts, 'get_checkout_session', [q('cs_smoke_1')]); + if (!session.includes('"complete"') || !session.includes('subscription')) { + throw new Error( + `checkout.session.completed did not produce expected row: ${session}` + ); + } + + step('ingest invoice.created, expect invoice row'); + await ingest(opts, { + eventId: 'evt_smoke_inv_1', + eventType: 'invoice.created', + payload: { + id: 'evt_smoke_inv_1', + type: 'invoice.created', + data: { + object: { + id: 'in_smoke_1', + customer: 'cus_smoke_1', + subscription: 'sub_smoke_1', + status: 'open', + amount_due: 999, + amount_paid: 0, + created: 1735000000, + }, + }, + }, + }); + let invoice = await call(opts, 'list_invoices', [q('cus_smoke_1')]); + if (!invoice.includes('in_smoke_1')) { + throw new Error(`invoice.created did not produce row: ${invoice}`); + } + + step('ingest invoice.paid, expect status=paid + carry-over fields'); + await ingest(opts, { + eventId: 'evt_smoke_inv_2', + eventType: 'invoice.paid', + payload: { + id: 'evt_smoke_inv_2', + type: 'invoice.paid', + data: { + object: { + id: 'in_smoke_1', + customer: 'cus_smoke_1', + status: 'paid', + amount_paid: 999, + }, + }, + }, + }); + invoice = await call(opts, 'list_invoices', [q('cus_smoke_1')]); + if (!invoice.includes('"paid"')) { + throw new Error(`invoice.paid did not flip status: ${invoice}`); + } + + step('ingest payment_intent.succeeded standalone, expect payment row'); + await ingest(opts, { + eventId: 'evt_smoke_pay_1', + eventType: 'payment_intent.succeeded', + payload: { + id: 'evt_smoke_pay_1', + type: 'payment_intent.succeeded', + data: { + object: { + id: 'pi_smoke_1', + customer: 'cus_smoke_2', + amount: 1999, + currency: 'usd', + status: 'succeeded', + created: 1735000100, + metadata: { userId: 'u_smoke_2' }, + }, + }, + }, + }); + const payment = await call(opts, 'get_payment', [q('pi_smoke_1')]); + if (!payment.includes('1999') || !payment.includes('"usd"')) { + throw new Error(`payment_intent.succeeded did not produce row: ${payment}`); + } + + step('negative: replay unknown event_id, expect failure'); + const replayMissing = await expectCallFails(opts, 'replay_webhook_event', [ + q('evt_does_not_exist_xyz'), + ]); + if (!replayMissing.toLowerCase().includes('not_found')) { + throw new Error( + `expected not_found error; got: ${replayMissing.slice(0, 400)}` + ); + } + + step('verify idempotency: re-ingest evt_smoke_cust_1, row unchanged'); + await ingest(opts, { + eventId: 'evt_smoke_cust_1', + eventType: 'customer.created', + payload: { + id: 'evt_smoke_cust_1', + type: 'customer.created', + data: { object: { id: 'cus_smoke_1', email: 'CHANGED@example.com' } }, + }, + }); + const customerAfter = await call(opts, 'get_customer', [q('cus_smoke_1')]); + if (!customerAfter.includes('smoke@example.com')) { + throw new Error(`replay broke customer row: ${customerAfter}`); + } + + step('done: stripe smoke test passed'); +} + +main().catch(err => { + process.stderr.write( + `\nSMOKE TEST FAILED: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); +}); diff --git a/spacetime-stripe-ts/scripts/test-unit.ts b/spacetime-stripe-ts/scripts/test-unit.ts new file mode 100644 index 00000000000..ea38e2dd5d5 --- /dev/null +++ b/spacetime-stripe-ts/scripts/test-unit.ts @@ -0,0 +1,116 @@ +import * as assert from 'node:assert/strict'; +import { Timestamp } from 'spacetimedb'; +import { latestSubscription } from '../src/submodule/subscription-order.ts'; +import { buildStripeHttpRequest } from '../src/submodule/http.ts'; +import { parseStripeEventMetadata } from '../src/submodule/webhook-metadata.ts'; +import { + validateWebhookRequestBody, + validateWebhookRequestHeaders, +} from '../src/submodule/webhook-request.ts'; + +const customerEvent = { + id: 'evt_1', + type: 'customer.created', + livemode: false, + data: { + object: { + id: 'cus_1', + email: 'customer@example.com', + name: 'Customer', + metadata: { userId: 'user-1' }, + }, + }, +}; + +assert.deepEqual(parseStripeEventMetadata(JSON.stringify(customerEvent)), { + eventId: 'evt_1', + eventType: 'customer.created', + livemode: false, +}); +assert.equal(parseStripeEventMetadata('{bad json'), undefined); +assert.equal(parseStripeEventMetadata('{"id":"evt_1"}'), undefined); + +const request = buildStripeHttpRequest({ + method: 'post', + path: '/v1/customers', + secretKey: 'sk_test_secret', + stripeVersion: '2025-01-01', + formBody: 'email=user%40example.com', + idempotencyKey: 'customer-user-1', +}); +assert.equal(request.url, 'https://api.stripe.com/v1/customers'); +assert.equal(request.method, 'POST'); +assert.equal(request.headers.Authorization, 'Bearer sk_test_secret'); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'GET', + path: 'https://attacker.example/collect', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_path_invalid/ +); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'GET', + path: '/v1/customers\u007fblocked', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_path_invalid/ +); +assert.throws( + () => + buildStripeHttpRequest({ + method: 'TRACE', + path: '/v1/customers', + secretKey: 'sk_test_secret', + stripeVersion: undefined, + formBody: undefined, + idempotencyKey: undefined, + }), + /stripe\.request_method_invalid/ +); + +assert.deepEqual(validateWebhookRequestHeaders('GET', null, undefined), { + status: 405, + error: 'method not allowed', +}); +assert.equal( + validateWebhookRequestHeaders('POST', String(1024 * 1024 + 1), undefined) + ?.status, + 413 +); +assert.equal( + validateWebhookRequestHeaders('POST', null, 'x'.repeat(8193))?.status, + 431 +); +assert.equal(validateWebhookRequestHeaders('POST', null, 'short'), undefined); +assert.equal(validateWebhookRequestBody('')?.status, 400); +assert.equal( + validateWebhookRequestBody('x'.repeat(1024 * 1024 + 1))?.status, + 413 +); +assert.equal(validateWebhookRequestBody('{}'), undefined); + +const subscriptions = Array.from({ length: 5001 }, (_, index) => ({ + stripeSubscriptionId: `sub_${index}`, + insertedAt: new Timestamp(BigInt(index)), +})); +assert.equal(latestSubscription(subscriptions.values()), subscriptions[5000]); +assert.equal(latestSubscription([]), undefined); +assert.equal( + latestSubscription([ + { stripeSubscriptionId: 'sub_b', insertedAt: new Timestamp(1n) }, + { stripeSubscriptionId: 'sub_a', insertedAt: new Timestamp(1n) }, + ])?.stripeSubscriptionId, + 'sub_b' +); + +console.log('stripe unit tests passed'); diff --git a/spacetime-stripe-ts/scripts/type-alignment.ts b/spacetime-stripe-ts/scripts/type-alignment.ts new file mode 100644 index 00000000000..3d5af0facc3 --- /dev/null +++ b/spacetime-stripe-ts/scripts/type-alignment.ts @@ -0,0 +1,40 @@ +import type Stripe from 'stripe'; +import type * as v from 'valibot'; +import type { + ParsedStripeEvent, + vStripeBillingPortalSessionResponse, + vStripeCheckoutSessionResponse, +} from '../src/submodule/schema.ts'; + +type Assert = T; +type IsAssignable = [From] extends [To] ? true : false; + +type _StripeEvents = Assert< + IsAssignable< + | Stripe.CustomerCreatedEvent + | Stripe.CustomerUpdatedEvent + | Stripe.CustomerSubscriptionCreatedEvent + | Stripe.CustomerSubscriptionUpdatedEvent + | Stripe.CustomerSubscriptionDeletedEvent + | Stripe.CheckoutSessionCompletedEvent + | Stripe.InvoiceCreatedEvent + | Stripe.InvoiceFinalizedEvent + | Stripe.InvoicePaidEvent + | Stripe.InvoicePaymentSucceededEvent + | Stripe.InvoicePaymentFailedEvent + | Stripe.PaymentIntentSucceededEvent, + ParsedStripeEvent + > +>; +type _CheckoutSessionResponse = Assert< + IsAssignable< + Stripe.Checkout.Session, + v.InferOutput + > +>; +type _BillingPortalSessionResponse = Assert< + IsAssignable< + Stripe.BillingPortal.Session, + v.InferOutput + > +>; diff --git a/spacetime-stripe-ts/src/index.ts b/spacetime-stripe-ts/src/index.ts new file mode 100644 index 00000000000..38e3cf581fe --- /dev/null +++ b/spacetime-stripe-ts/src/index.ts @@ -0,0 +1,53 @@ +export { default, init } from './submodule/schema'; +export { + upsertCustomer, + upsertSubscription, + updatePaymentCustomer, + updateSubscriptionQuantityInternal, + ingestStripeWebhook, + replayWebhookEvent, +} from './submodule/operations'; +export { + validateStripePrice, + getRemoteCheckoutSession, + getWebhookEventCount, + stripeApiRequest, + createCustomer, + createOrUpdateCustomer, + updateSubscriptionMetadata, + getOrCreateCustomer, + createCheckoutSession, + createCustomerPortalSession, + cancelSubscription, + reactivateSubscription, + updateSubscriptionQuantity, +} from './submodule/operations/billing'; +export { + getCustomer, + getCustomerByEmail, + getCustomerByUserId, + getSubscription, + listSubscriptions, + listSubscriptionsWithCreationTime, + getSubscriptionByOrgId, + listSubscriptionsByOrgId, + listSubscriptionsByUserId, + getPayment, + listPayments, + listPaymentsByUserId, + listPaymentsByOrgId, + listInvoices, + listInvoicesByOrgId, + listInvoicesByUserId, + getCheckoutSession, + listCheckoutSessions, +} from './submodule/operations/queries'; +export { stripeWebhookHandler } from './submodule/operations/webhook'; + +export { + setStripeConfig, + setStripeWebhookSigningSecret, + getStripeConfigStatus, +} from './submodule/config'; +export { addAdminIdentity, removeAdminIdentity } from './submodule/auth'; +export { stripeWebhookRouter } from './submodule/router'; diff --git a/spacetime-stripe-ts/src/submodule.ts b/spacetime-stripe-ts/src/submodule.ts new file mode 100644 index 00000000000..206919f1954 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule.ts @@ -0,0 +1,23 @@ +export { default } from './submodule/schema'; +export { installStripe } from './submodule/install'; +export { + upsertCustomer, + upsertSubscription, + updatePaymentCustomer, + updateSubscriptionQuantityInternal, + ingestStripeWebhook, + replayWebhookEvent, +} from './submodule/operations'; +export * from './submodule/operations/billing'; +export * from './submodule/operations/queries'; +export { + handleStripeWebhook, + stripeWebhookHandler, +} from './submodule/operations/webhook'; + +export { + setStripeConfig, + setStripeWebhookSigningSecret, + getStripeConfigStatus, +} from './submodule/config'; +export { addAdminIdentity, removeAdminIdentity } from './submodule/auth'; diff --git a/spacetime-stripe-ts/src/submodule/auth.ts b/spacetime-stripe-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..e7c34d4599a --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/auth.ts @@ -0,0 +1,79 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './validation'; + +// Admin gate. Fresh publishes seed the owner via init. Public submodule calls +// never bootstrap admin state from "first caller wins". +type Sender = WriteCtx['sender']; +type ModuleTimestamp = WriteCtx['timestamp']; + +export type AdminVerdict = 'admin' | 'denied'; + +export function isAdmin(ctx: WriteCtx, sender: Sender): boolean { + return ctx.db.stripeAdminIdentity.identity.find(sender) != null; +} + +export function adminVerdict(ctx: WriteCtx, sender: Sender): AdminVerdict { + return isAdmin(ctx, sender) ? 'admin' : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throwSenderError('stripe.not_authorized'); +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('stripe.not_authorized'); +} + +// For owner-gated repair/setup code only. Do not call from a public bootstrap path. +export function seedAdmin( + ctx: WriteCtx, + sender: Sender, + timestamp: ModuleTimestamp +) { + if (ctx.db.stripeAdminIdentity.identity.find(sender) != null) return; + ctx.db.stripeAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} + +export const addAdminIdentity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + if (tx.db.stripeAdminIdentity.identity.find(identity) == null) { + tx.db.stripeAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const removeAdminIdentity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + const existing = tx.db.stripeAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.stripeAdminIdentity.count() <= 1n) { + throwSenderError('stripe.cannot_remove_last_admin'); + } + tx.db.stripeAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/src/submodule/config.ts b/spacetime-stripe-ts/src/submodule/config.ts new file mode 100644 index 00000000000..cb7fa811ea8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/config.ts @@ -0,0 +1,135 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { adminVerdict, denyIfNotAdmin } from './auth'; +import { throwSenderError } from './validation'; + +export type StripeConfig = { + secretKey: string; + stripeVersion: string | undefined; + webhookSigningSecret: string | undefined; +}; + +export function loadConfigOrThrow(ctx: WriteCtx): StripeConfig { + const row = ctx.db.stripeConfig.singleton.find(true); + if (!row) { + throwSenderError( + 'stripe.config_not_set: call set_stripe_config(...) first' + ); + } + return { + secretKey: row.secretKey, + stripeVersion: row.stripeVersion, + webhookSigningSecret: row.webhookSigningSecret, + }; +} + +export function loadConfigOrThrowFromProcedure( + ctx: ProcedureModuleCtx +): StripeConfig { + return ctx.withTx(tx => loadConfigOrThrow(tx)); +} + +function upsertConfig( + ctx: WriteCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + webhookSigningSecret: string | undefined; + } +) { + const existing = ctx.db.stripeConfig.singleton.find(true); + const row = { + singleton: true, + secretKey: args.secretKey, + stripeVersion: args.stripeVersion ?? existing?.stripeVersion, + webhookSigningSecret: + args.webhookSigningSecret ?? existing?.webhookSigningSecret, + updatedAt: ctx.timestamp, + }; + if (!existing) { + ctx.db.stripeConfig.insert(row); + return; + } + ctx.db.stripeConfig.singleton.update(row); +} + +export const setStripeConfig = spacetimedb.procedure( + { + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + ctx.withTx(tx => { + upsertConfig(tx, { + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + webhookSigningSecret: args.webhookSigningSecret, + }); + }); + return {}; + } +); + +export const setStripeWebhookSigningSecret = spacetimedb.procedure( + { webhookSigningSecret: t.string() }, + t.unit(), + (ctx, { webhookSigningSecret }) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + const secret = webhookSigningSecret.trim(); + if (!secret) throwSenderError('stripe.invalid_webhook_signing_secret'); + ctx.withTx(tx => { + const existing = tx.db.stripeConfig.singleton.find(true); + if (!existing) { + throwSenderError( + 'stripe.config_not_set: call set_stripe_config(...) first' + ); + } + tx.db.stripeConfig.singleton.update({ + ...existing, + webhookSigningSecret: secret, + updatedAt: ctx.timestamp, + }); + }); + return {}; + } +); + +export const getStripeConfigStatus = spacetimedb.procedure( + {}, + t.object('StripeConfigStatus', { + isConfigured: t.bool(), + hasWebhookSecret: t.bool(), + stripeVersion: t.option(t.string()), + secretKeyLength: t.u16(), + }), + ctx => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + return ctx.withTx(tx => { + const row = tx.db.stripeConfig.singleton.find(true); + if (!row) { + return { + isConfigured: false, + hasWebhookSecret: false, + stripeVersion: undefined, + secretKeyLength: 0, + }; + } + return { + isConfigured: true, + hasWebhookSecret: row.webhookSigningSecret !== undefined, + stripeVersion: row.stripeVersion, + secretKeyLength: row.secretKey.length, + }; + }); + } +); diff --git a/spacetime-stripe-ts/src/submodule/http.ts b/spacetime-stripe-ts/src/submodule/http.ts new file mode 100644 index 00000000000..3ce4ba8ca98 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/http.ts @@ -0,0 +1,79 @@ +const STRIPE_API_ORIGIN = 'https://api.stripe.com'; +const ALLOWED_METHODS = new Set(['GET', 'POST', 'DELETE']); +const MAX_PATH_LENGTH = 2048; +const MAX_FORM_BODY_LENGTH = 64 * 1024; +const MAX_IDEMPOTENCY_KEY_LENGTH = 255; + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +export type StripeHttpRequest = { + method: string; + url: string; + headers: Record; + body: string | undefined; +}; + +function validatePath(path: string): string { + const normalized = path.trim(); + if (!normalized.startsWith('/v1/')) { + throw new Error('stripe.request_path_invalid'); + } + if ( + normalized.startsWith('//') || + normalized.includes('\\') || + normalized.includes('#') + ) { + throw new Error('stripe.request_path_invalid'); + } + if (normalized.length > MAX_PATH_LENGTH || hasControlCharacter(normalized)) { + throw new Error('stripe.request_path_invalid'); + } + return normalized; +} + +export function buildStripeHttpRequest(args: { + method: string; + path: string; + secretKey: string; + stripeVersion: string | undefined; + formBody: string | undefined; + idempotencyKey: string | undefined; +}): StripeHttpRequest { + const method = args.method.trim().toUpperCase(); + if (!ALLOWED_METHODS.has(method)) { + throw new Error('stripe.request_method_invalid'); + } + + const path = validatePath(args.path); + const body = args.formBody?.length ? args.formBody : undefined; + if (body !== undefined && body.length > MAX_FORM_BODY_LENGTH) { + throw new Error('stripe.request_body_too_large'); + } + if ( + args.idempotencyKey && + args.idempotencyKey.length > MAX_IDEMPOTENCY_KEY_LENGTH + ) { + throw new Error('stripe.idempotency_key_too_long'); + } + + const headers: Record = { + Authorization: `Bearer ${args.secretKey}`, + }; + if (args.stripeVersion) headers['Stripe-Version'] = args.stripeVersion; + if (args.idempotencyKey) headers['Idempotency-Key'] = args.idempotencyKey; + if (body !== undefined) + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + + return { + method, + url: `${STRIPE_API_ORIGIN}${path}`, + headers, + body, + }; +} diff --git a/spacetime-stripe-ts/src/submodule/install.ts b/spacetime-stripe-ts/src/submodule/install.ts new file mode 100644 index 00000000000..80e67faf4a8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema'; + +export function installStripe(ctx: ReducerModuleCtx) { + if (ctx.db.stripeAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.stripeAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-stripe-ts/src/submodule/limits.ts b/spacetime-stripe-ts/src/submodule/limits.ts new file mode 100644 index 00000000000..daa5fd01a6c --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/limits.ts @@ -0,0 +1,3 @@ +export const MAX_WEBHOOK_BODY_LENGTH = 1024 * 1024; +export const MAX_WEBHOOK_HEADER_LENGTH = 8192; +export const MAX_WEBHOOK_METADATA_LENGTH = 255; diff --git a/spacetime-stripe-ts/src/submodule/operations.ts b/spacetime-stripe-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..83b2920ee7f --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations.ts @@ -0,0 +1,906 @@ +import * as v from 'valibot'; +import { + SenderError, + t, + WebhookEventStatus, + type WebhookEventStatusValue, + spacetimedb, + vStripeEvent, + vStripeIdResponse, + extractExpandableId, + extractExpandableIdOrNull, + type ParsedStripeEvent, + type ProcedureModuleCtx, + type ReducerModuleCtx, + type TransactionModuleCtx, + type WriteCtx, + type JsonRecord, + type ModuleTimestamp, +} from './schema'; +import { verifyStripeSignature } from '@spacetimedb/crypto'; +import { adminVerdict, denyIfNotAdmin, requireAdmin } from './auth'; +import { parseStripeEventMetadata } from './webhook-metadata'; +import { buildStripeHttpRequest } from './http'; +import { + MAX_WEBHOOK_BODY_LENGTH, + MAX_WEBHOOK_HEADER_LENGTH, + MAX_WEBHOOK_METADATA_LENGTH, +} from './limits'; +import { + assertExhaustive, + safeJsonParse, + summarizeIssues, + throwSenderError, +} from './validation'; + +export function requireProcedureAdmin(ctx: ProcedureModuleCtx): void { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); +} + +export function withAdminTx( + ctx: ProcedureModuleCtx, + read: (tx: TransactionModuleCtx) => T +): T { + requireProcedureAdmin(ctx); + return ctx.withTx(read); +} + +const MAX_QUERY_ROWS = 1000; + +export function takeRows(rows: Iterable, limit = MAX_QUERY_ROWS): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null; +} + +export function maybeString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +export function maybeBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +export function maybeNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +export function maybeInt(value: unknown): number | undefined { + const parsed = maybeNumber(value); + return parsed === undefined || !Number.isInteger(parsed) ? undefined : parsed; +} + +export function maybeBigIntFromUnknown(value: unknown): bigint | undefined { + const parsed = maybeInt(value); + return parsed === undefined ? undefined : BigInt(parsed); +} + +export function maybeId(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (!isRecord(value)) return undefined; + return maybeString(value.id); +} + +export function stripeErrorSuffix(body: string): string { + const parsed = safeJsonParse(body); + if (isRecord(parsed)) { + const errorPayload = isRecord(parsed.error) ? parsed.error : undefined; + if (errorPayload) { + const type = maybeString(errorPayload.type); + const code = maybeString(errorPayload.code); + const message = maybeString(errorPayload.message); + const requestLogUrl = maybeString(errorPayload.request_log_url); + const parts: string[] = []; + if (type) parts.push(`type=${type}`); + if (code) parts.push(`code=${code}`); + if (message) + parts.push(`msg=${message.replace(/\s+/g, ' ').slice(0, 240)}`); + if (requestLogUrl) parts.push(`log=${requestLogUrl}`); + if (parts.length > 0) return `:${parts.join('|')}`; + } + } + + const compact = body.replace(/\s+/g, ' ').trim(); + if (!compact) return ''; + return `:body=${compact.slice(0, 240)}`; +} + +export function toJsonString(value: unknown): string | undefined { + if (value === undefined) return undefined; + try { + return JSON.stringify(value); + } catch { + return undefined; + } +} + +export function metadataInfo(metadata: unknown): { + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; +} { + if (!isRecord(metadata)) { + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + } + return { + metadataJson: toJsonString(metadata), + orgId: maybeString(metadata.orgId), + userId: maybeString(metadata.userId), + }; +} + +export function coerceMetadataFromJson(metadataJson: string | undefined) { + if (!metadataJson) { + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + } + const parsed = safeJsonParse(metadataJson); + const details = metadataInfo(parsed); + return { + metadataJson: details.metadataJson ?? metadataJson, + orgId: details.orgId, + userId: details.userId, + }; +} + +export function deriveCancelAtPeriodEnd( + cancelAtUnix: bigint | undefined, + currentPeriodEndUnix: bigint +): boolean { + if (cancelAtUnix === undefined || currentPeriodEndUnix <= 0n) return false; + const tolerance = 5n * 60n; + const delta = + cancelAtUnix > currentPeriodEndUnix + ? cancelAtUnix - currentPeriodEndUnix + : currentPeriodEndUnix - cancelAtUnix; + return delta <= tolerance; +} + +export function formPairsToBody( + pairs: Array<[string, string | undefined]> +): string { + const encoded: string[] = []; + for (const [key, value] of pairs) { + if (value === undefined) continue; + encoded.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + } + return encoded.join('&'); +} + +export function metadataJsonToFormPairs( + keyPrefix: string, + metadataJson: string | undefined +) { + if (!metadataJson) return [] as Array<[string, string]>; + const parsed = safeJsonParse(metadataJson); + if (!isRecord(parsed)) return [] as Array<[string, string]>; + + const out: Array<[string, string]> = []; + for (const [k, raw] of Object.entries(parsed)) { + if (raw === undefined || raw === null) continue; + out.push([`${keyPrefix}[${k}]`, String(raw)]); + } + return out; +} + +export function upsertCustomerRow( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeCustomerId: string; + appUserId: string | undefined; + email: string | undefined; + name: string | undefined; + metadataJson: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeCustomer.stripeCustomerId.find( + args.stripeCustomerId + ); + const row = { + stripeCustomerId: args.stripeCustomerId, + appUserId: args.appUserId ?? existing?.appUserId, + email: args.email ?? existing?.email, + name: args.name ?? existing?.name, + metadataJson: args.metadataJson ?? existing?.metadataJson, + userId: args.userId ?? existing?.userId, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeCustomer.insert(row); + return; + } + ctx.db.stripeCustomer.stripeCustomerId.update(row); +} + +export function upsertSubscriptionRow( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeSubscriptionId: string; + stripeCustomerId: string; + status: string; + currentPeriodEndUnix: bigint; + cancelAtPeriodEnd: boolean; + cancelAtUnix: bigint | undefined; + quantity: bigint | undefined; + priceId: string | undefined; + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + const row = { + stripeSubscriptionId: args.stripeSubscriptionId, + stripeCustomerId: args.stripeCustomerId, + status: args.status, + currentPeriodEndUnix: args.currentPeriodEndUnix, + cancelAtPeriodEnd: args.cancelAtPeriodEnd, + cancelAtUnix: args.cancelAtUnix, + quantity: args.quantity, + priceId: args.priceId ?? existing?.priceId, + metadataJson: args.metadataJson ?? existing?.metadataJson, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeSubscription.insert(row); + return; + } + ctx.db.stripeSubscription.stripeSubscriptionId.update(row); +} + +export function upsertCheckoutSession( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeCheckoutSessionId: string; + stripeCustomerId: string | undefined; + status: string; + mode: string; + metadataJson: string | undefined; + } +) { + const existing = ctx.db.stripeCheckoutSession.stripeCheckoutSessionId.find( + args.stripeCheckoutSessionId + ); + const row = { + stripeCheckoutSessionId: args.stripeCheckoutSessionId, + stripeCustomerId: args.stripeCustomerId ?? existing?.stripeCustomerId, + status: args.status, + mode: args.mode, + metadataJson: args.metadataJson ?? existing?.metadataJson, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeCheckoutSession.insert(row); + return; + } + ctx.db.stripeCheckoutSession.stripeCheckoutSessionId.update(row); +} + +export function upsertPayment( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripePaymentIntentId: string; + stripeCustomerId: string | undefined; + amount: bigint; + currency: string; + status: string; + createdUnix: bigint; + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripePayment.stripePaymentIntentId.find( + args.stripePaymentIntentId + ); + const row = { + stripePaymentIntentId: args.stripePaymentIntentId, + stripeCustomerId: args.stripeCustomerId ?? existing?.stripeCustomerId, + amount: args.amount, + currency: args.currency, + status: args.status, + createdUnix: args.createdUnix, + metadataJson: args.metadataJson ?? existing?.metadataJson, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripePayment.insert(row); + return; + } + ctx.db.stripePayment.stripePaymentIntentId.update(row); +} + +export function upsertInvoice( + ctx: WriteCtx, + now: ModuleTimestamp, + args: { + stripeInvoiceId: string; + stripeCustomerId: string; + stripeSubscriptionId: string | undefined; + status: string; + amountDue: bigint; + amountPaid: bigint; + createdUnix: bigint; + orgId: string | undefined; + userId: string | undefined; + } +) { + const existing = ctx.db.stripeInvoice.stripeInvoiceId.find( + args.stripeInvoiceId + ); + const row = { + stripeInvoiceId: args.stripeInvoiceId, + stripeCustomerId: args.stripeCustomerId, + stripeSubscriptionId: + args.stripeSubscriptionId ?? existing?.stripeSubscriptionId, + status: args.status, + amountDue: args.amountDue, + amountPaid: args.amountPaid, + createdUnix: args.createdUnix, + orgId: args.orgId ?? existing?.orgId, + userId: args.userId ?? existing?.userId, + insertedAt: existing?.insertedAt ?? now, + updatedAt: now, + }; + + if (!existing) { + ctx.db.stripeInvoice.insert(row); + return; + } + ctx.db.stripeInvoice.stripeInvoiceId.update(row); +} + +export function updateWebhookStatus( + ctx: ReducerModuleCtx, + eventId: string, + status: WebhookEventStatusValue, + errorMessage: string | undefined +) { + const existing = ctx.db.stripeWebhookEvent.eventId.find(eventId); + if (!existing) return; + + const isTerminal = + status.tag === 'Processed' || + status.tag === 'Ignored' || + status.tag === 'Failed'; + const updated = { + ...existing, + status, + errorMessage, + processedAt: isTerminal ? ctx.timestamp : existing.processedAt, + }; + + ctx.db.stripeWebhookEvent.eventId.update(updated); +} + +export function metadataInfoFromRecord( + metadata: Record | null | undefined +): { + metadataJson: string | undefined; + orgId: string | undefined; + userId: string | undefined; +} { + if (!metadata) + return { metadataJson: undefined, orgId: undefined, userId: undefined }; + return { + metadataJson: toJsonString(metadata), + orgId: metadata.orgId, + userId: metadata.userId, + }; +} + +export function toBigIntOrZero(n: number | undefined | null): bigint { + return n === undefined || n === null ? 0n : BigInt(n); +} + +export function toBigIntOrUndefined( + n: number | undefined | null +): bigint | undefined { + return n === undefined || n === null ? undefined : BigInt(n); +} + +const HANDLED_EVENT_TYPES: ReadonlySet = new Set([ + 'customer.created', + 'customer.updated', + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted', + 'checkout.session.completed', + 'invoice.created', + 'invoice.finalized', + 'invoice.paid', + 'invoice.payment_succeeded', + 'invoice.payment_failed', + 'payment_intent.succeeded', +]); + +export function applyStripeEvent( + ctx: ReducerModuleCtx, + payloadJson: string +): { status: WebhookEventStatusValue; error: string | undefined } { + const parsedJson = safeJsonParse(payloadJson); + if (parsedJson === undefined) { + return { status: WebhookEventStatus.Failed, error: 'invalid JSON payload' }; + } + + const result = v.safeParse(vStripeEvent, parsedJson); + if (!result.success) { + // Distinguish unhandled type (ignore) from handled-but-malformed (fail). + const eventTypeRaw = + typeof parsedJson === 'object' && parsedJson !== null + ? (parsedJson as Record).type + : undefined; + const isHandledType = + typeof eventTypeRaw === 'string' && HANDLED_EVENT_TYPES.has(eventTypeRaw); + if (!isHandledType) { + return { status: WebhookEventStatus.Ignored, error: undefined }; + } + return { + status: WebhookEventStatus.Failed, + error: `payload validation failed: ${summarizeIssues(result.issues)}`, + }; + } + + return { status: dispatchEvent(ctx, result.output), error: undefined }; +} + +type ParsedInvoiceObject = Extract< + ParsedStripeEvent, + { type: 'invoice.paid' } +>['data']['object']; + +function syncInvoiceEvent( + ctx: ReducerModuleCtx, + obj: ParsedInvoiceObject, + status: string +): WebhookEventStatusValue { + const existing = ctx.db.stripeInvoice.stripeInvoiceId.find(obj.id); + const payloadCustomerId = extractExpandableIdOrNull(obj.customer); + const customerId = existing?.stripeCustomerId ?? payloadCustomerId; + if (customerId === null) return WebhookEventStatus.Failed; + const payloadSubscriptionId = + obj.subscription === undefined + ? undefined + : (extractExpandableIdOrNull(obj.subscription) ?? undefined); + const subscriptionId = + existing?.stripeSubscriptionId ?? payloadSubscriptionId; + const subscription = subscriptionId + ? ctx.db.stripeSubscription.stripeSubscriptionId.find(subscriptionId) + : undefined; + upsertInvoice(ctx, ctx.timestamp, { + stripeInvoiceId: obj.id, + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId, + status, + amountDue: toBigIntOrUndefined(obj.amount_due) ?? existing?.amountDue ?? 0n, + amountPaid: + toBigIntOrUndefined(obj.amount_paid) ?? existing?.amountPaid ?? 0n, + createdUnix: + toBigIntOrUndefined(obj.created) ?? existing?.createdUnix ?? 0n, + orgId: existing?.orgId ?? subscription?.orgId, + userId: existing?.userId ?? subscription?.userId, + }); + return WebhookEventStatus.Processed; +} + +function dispatchEvent( + ctx: ReducerModuleCtx, + event: ParsedStripeEvent +): WebhookEventStatusValue { + switch (event.type) { + case 'customer.created': + case 'customer.updated': { + const obj = event.data.object; + const meta = metadataInfoFromRecord(obj.metadata); + upsertCustomerRow(ctx, ctx.timestamp, { + stripeCustomerId: obj.id, + appUserId: undefined, + email: obj.email ?? undefined, + name: obj.name ?? undefined, + metadataJson: meta.metadataJson, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const obj = event.data.object; + const status = + event.type === 'customer.subscription.deleted' + ? 'canceled' + : obj.status; + const customerId = extractExpandableId(obj.customer); + const firstItem = obj.items?.data[0]; + const currentPeriodEnd = + toBigIntOrUndefined(firstItem?.current_period_end) ?? + toBigIntOrUndefined(obj.current_period_end) ?? + 0n; + const cancelAtUnix = toBigIntOrUndefined(obj.cancel_at); + const cancelAtPeriodEnd = + obj.cancel_at_period_end ?? + deriveCancelAtPeriodEnd(cancelAtUnix, currentPeriodEnd); + const meta = metadataInfoFromRecord(obj.metadata); + upsertSubscriptionRow(ctx, ctx.timestamp, { + stripeSubscriptionId: obj.id, + stripeCustomerId: customerId, + status, + currentPeriodEndUnix: currentPeriodEnd, + cancelAtPeriodEnd, + cancelAtUnix, + quantity: toBigIntOrUndefined(firstItem?.quantity), + priceId: firstItem?.price?.id, + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + case 'checkout.session.completed': { + const obj = event.data.object; + const meta = metadataInfoFromRecord(obj.metadata); + const customerId = + obj.customer === undefined + ? undefined + : (extractExpandableIdOrNull(obj.customer) ?? undefined); + upsertCheckoutSession(ctx, ctx.timestamp, { + stripeCheckoutSessionId: obj.id, + stripeCustomerId: customerId, + status: 'complete', + mode: obj.mode ?? 'payment', + metadataJson: meta.metadataJson, + }); + return WebhookEventStatus.Processed; + } + case 'invoice.created': + case 'invoice.finalized': { + const obj = event.data.object; + return syncInvoiceEvent(ctx, obj, obj.status ?? 'open'); + } + case 'invoice.paid': + case 'invoice.payment_succeeded': { + return syncInvoiceEvent(ctx, event.data.object, 'paid'); + } + case 'invoice.payment_failed': { + return syncInvoiceEvent(ctx, event.data.object, 'open'); + } + case 'payment_intent.succeeded': { + const obj = event.data.object; + // Invoice events own invoice-attached payment state. + const invoiceId = + obj.invoice === undefined + ? null + : extractExpandableIdOrNull(obj.invoice); + if (invoiceId !== null && invoiceId !== undefined) + return WebhookEventStatus.Ignored; + + const customerId = + obj.customer === undefined + ? null + : extractExpandableIdOrNull(obj.customer); + const meta = metadataInfoFromRecord(obj.metadata); + upsertPayment(ctx, ctx.timestamp, { + stripePaymentIntentId: obj.id, + stripeCustomerId: customerId ?? undefined, + amount: toBigIntOrZero(obj.amount), + currency: obj.currency ?? 'unknown', + status: obj.status ?? 'succeeded', + createdUnix: toBigIntOrZero(obj.created), + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + return WebhookEventStatus.Processed; + } + default: + return assertExhaustive(event); + } +} + +export function callStripe( + ctx: ProcedureModuleCtx, + args: { + method: string; + path: string; + secretKey: string; + stripeVersion: string | undefined; + formBody: string | undefined; + idempotencyKey: string | undefined; + } +) { + let request; + try { + request = buildStripeHttpRequest(args); + } catch (error) { + throw new SenderError( + error instanceof Error ? error.message : 'stripe.request_invalid' + ); + } + const response = ctx.http.fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + }); + return { + status: response.status, + body: response.text(), + }; +} + +export function createCustomerInStripeAndSync( + ctx: ProcedureModuleCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + email: string | undefined; + name: string | undefined; + metadataJson: string | undefined; + idempotencyKey: string | undefined; + } +): string { + const formPairs: Array<[string, string | undefined]> = [ + ['email', args.email], + ['name', args.name], + ]; + for (const [k, v] of metadataJsonToFormPairs('metadata', args.metadataJson)) { + formPairs.push([k, v]); + } + + const result = callStripe(ctx, { + method: 'POST', + path: '/v1/customers', + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + idempotencyKey: args.idempotencyKey + ? `create_customer_${args.idempotencyKey}` + : undefined, + formBody: formPairsToBody(formPairs), + }); + if (result.status < 200 || result.status >= 300) { + throwSenderError( + `stripe.create_customer_failed:${result.status}${stripeErrorSuffix(result.body)}` + ); + } + + const parsedBody = safeJsonParse(result.body); + const idResult = v.safeParse(vStripeIdResponse, parsedBody); + if (!idResult.success) { + throwSenderError( + `stripe.create_customer_invalid_response:${summarizeIssues(idResult.issues)}` + ); + } + const customerId = idResult.output.id; + + const details = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + upsertCustomerRow(tx, ctx.timestamp, { + stripeCustomerId: customerId, + appUserId: undefined, + email: args.email, + name: args.name, + metadataJson: details.metadataJson, + userId: details.userId, + }); + }); + return customerId; +} + +export const upsertCustomer = spacetimedb.reducer( + { + stripeCustomerId: t.string(), + appUserId: t.option(t.string()), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + userId: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + upsertCustomerRow(ctx, ctx.timestamp, { + stripeCustomerId: args.stripeCustomerId, + appUserId: args.appUserId, + email: args.email, + name: args.name, + metadataJson: args.metadataJson, + userId: args.userId, + }); + } +); + +export const upsertSubscription = spacetimedb.reducer( + { + stripeSubscriptionId: t.string(), + stripeCustomerId: t.string(), + status: t.string(), + currentPeriodEndUnix: t.i64(), + cancelAtPeriodEnd: t.bool(), + cancelAtUnix: t.option(t.i64()), + quantity: t.option(t.i64()), + priceId: t.option(t.string()), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + upsertSubscriptionRow(ctx, ctx.timestamp, { + stripeSubscriptionId: args.stripeSubscriptionId, + stripeCustomerId: args.stripeCustomerId, + status: args.status, + currentPeriodEndUnix: args.currentPeriodEndUnix, + cancelAtPeriodEnd: args.cancelAtPeriodEnd, + cancelAtUnix: args.cancelAtUnix, + quantity: args.quantity, + priceId: args.priceId, + metadataJson: args.metadataJson, + orgId: args.orgId, + userId: args.userId, + }); + } +); + +export const updatePaymentCustomer = spacetimedb.reducer( + { + stripePaymentIntentId: t.string(), + stripeCustomerId: t.string(), + }, + (ctx, { stripePaymentIntentId, stripeCustomerId }) => { + requireAdmin(ctx, ctx.sender); + const existing = ctx.db.stripePayment.stripePaymentIntentId.find( + stripePaymentIntentId + ); + if (!existing || existing.stripeCustomerId) return; + upsertPayment(ctx, ctx.timestamp, { + stripePaymentIntentId: existing.stripePaymentIntentId, + stripeCustomerId, + amount: existing.amount, + currency: existing.currency, + status: existing.status, + createdUnix: existing.createdUnix, + metadataJson: existing.metadataJson, + orgId: existing.orgId, + userId: existing.userId, + }); + } +); + +export const updateSubscriptionQuantityInternal = spacetimedb.reducer( + { + stripeSubscriptionId: t.string(), + quantity: t.i64(), + }, + (ctx, { stripeSubscriptionId, quantity }) => { + requireAdmin(ctx, ctx.sender); + const existing = + ctx.db.stripeSubscription.stripeSubscriptionId.find(stripeSubscriptionId); + if (!existing) return; + upsertSubscriptionRow(ctx, ctx.timestamp, { + stripeSubscriptionId: existing.stripeSubscriptionId, + stripeCustomerId: existing.stripeCustomerId, + status: existing.status, + currentPeriodEndUnix: existing.currentPeriodEndUnix, + cancelAtPeriodEnd: existing.cancelAtPeriodEnd, + cancelAtUnix: existing.cancelAtUnix, + quantity, + priceId: existing.priceId, + metadataJson: existing.metadataJson, + orgId: existing.orgId, + userId: existing.userId, + }); + } +); + +export const ingestStripeWebhook = spacetimedb.reducer( + { + eventId: t.string(), + eventType: t.string(), + livemode: t.bool(), + payloadJson: t.string(), + signatureHeader: t.option(t.string()), + }, + (ctx, { eventId, eventType, livemode, payloadJson, signatureHeader }) => { + if ( + eventId.length === 0 || + eventId.length > MAX_WEBHOOK_METADATA_LENGTH || + eventType.length === 0 || + eventType.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + throwSenderError('stripe.webhook_metadata_invalid'); + } + if (payloadJson.length > MAX_WEBHOOK_BODY_LENGTH) { + throwSenderError('stripe.webhook_payload_too_large'); + } + if ((signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH) { + throwSenderError('stripe.webhook_signature_too_large'); + } + + const cfg = ctx.db.stripeConfig.singleton.find(true); + if (!cfg?.webhookSigningSecret) { + throwSenderError('stripe.webhook_secret_not_configured'); + } + const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n); + const sigOk = verifyStripeSignature({ + rawBody: payloadJson, + signatureHeader: signatureHeader ?? '', + secret: cfg.webhookSigningSecret, + nowSeconds, + }); + if (!sigOk) throwSenderError('stripe.webhook_signature_mismatch'); + + const signedMetadata = parseStripeEventMetadata(payloadJson); + if (!signedMetadata) + throwSenderError('stripe.webhook_payload_missing_metadata'); + if ( + eventId !== signedMetadata.eventId || + eventType !== signedMetadata.eventType || + livemode !== signedMetadata.livemode + ) { + throwSenderError('stripe.webhook_metadata_mismatch'); + } + + const existing = ctx.db.stripeWebhookEvent.eventId.find( + signedMetadata.eventId + ); + if (existing) return; + + ctx.db.stripeWebhookEvent.insert({ + eventId: signedMetadata.eventId, + eventType: signedMetadata.eventType, + livemode: signedMetadata.livemode, + signatureHeader, + payloadJson, + status: WebhookEventStatus.Received, + errorMessage: undefined, + receivedAt: ctx.timestamp, + processedAt: undefined, + }); + + const outcome = applyStripeEvent(ctx, payloadJson); + updateWebhookStatus( + ctx, + signedMetadata.eventId, + outcome.status, + outcome.error + ); + } +); + +export const replayWebhookEvent = spacetimedb.reducer( + { eventId: t.string() }, + (ctx, { eventId }) => { + // Administrators may run this operation over stored events. + requireAdmin(ctx, ctx.sender); + const event = ctx.db.stripeWebhookEvent.eventId.find(eventId); + if (!event) throwSenderError(`stripe.webhook_event_not_found:${eventId}`); + const outcome = applyStripeEvent(ctx, event.payloadJson); + updateWebhookStatus(ctx, eventId, outcome.status, outcome.error); + } +); diff --git a/spacetime-stripe-ts/src/submodule/operations/billing.ts b/spacetime-stripe-ts/src/submodule/operations/billing.ts new file mode 100644 index 00000000000..fa9a3d5bdbf --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/billing.ts @@ -0,0 +1,678 @@ +import * as v from 'valibot'; +import { + t, + spacetimedb, + stripeHttpResponse, + checkoutSessionResult, + createCustomerResult, + getOrCreateCustomerResult, + portalSessionResult, + vStripeCheckoutSessionResponse, + vStripeBillingPortalSessionResponse, + type ProcedureModuleCtx, + type JsonRecord, +} from '../schema'; +import { loadConfigOrThrowFromProcedure } from '../config'; +import { adminVerdict, denyIfNotAdmin } from '../auth'; +import { + safeJsonParse, + summarizeIssues, + throwSenderError, +} from '../validation'; + +import { + requireProcedureAdmin, + withAdminTx, + isRecord, + maybeString, + maybeBoolean, + maybeBigIntFromUnknown, + maybeId, + stripeErrorSuffix, + metadataInfo, + coerceMetadataFromJson, + deriveCancelAtPeriodEnd, + formPairsToBody, + metadataJsonToFormPairs, + upsertCustomerRow, + upsertSubscriptionRow, + callStripe, + createCustomerInStripeAndSync, +} from '../operations'; + +export const validateStripePrice = spacetimedb.procedure( + { priceId: t.string() }, + t.object('ValidateStripePriceResult', { + valid: t.bool(), + status: t.u16(), + active: t.option(t.bool()), + currency: t.option(t.string()), + unitAmount: t.option(t.i64()), + livemode: t.option(t.bool()), + type: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + }), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'GET', + path: `/v1/prices/${args.priceId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + const parsed = safeJsonParse(response.body); + const isOk = response.status >= 200 && response.status < 300; + if (!isOk) { + const err = + isRecord(parsed) && isRecord(parsed.error) ? parsed.error : undefined; + return { + valid: false, + status: response.status, + active: undefined, + currency: undefined, + unitAmount: undefined, + livemode: undefined, + type: undefined, + message: + maybeString(err?.message) ?? `Stripe returned ${response.status}.`, + code: maybeString(err?.code), + errorType: maybeString(err?.type), + }; + } + const data = isRecord(parsed) ? parsed : {}; + return { + valid: true, + status: response.status, + active: maybeBoolean(data.active), + currency: maybeString(data.currency), + unitAmount: maybeBigIntFromUnknown(data.unit_amount), + livemode: maybeBoolean(data.livemode), + type: maybeString(data.type), + message: undefined, + code: undefined, + errorType: undefined, + }; + } +); + +export const getRemoteCheckoutSession = spacetimedb.procedure( + { sessionId: t.string() }, + t.object('RemoteCheckoutSessionResult', { + ok: t.bool(), + status: t.u16(), + sessionId: t.option(t.string()), + paymentStatus: t.option(t.string()), + sessionStatus: t.option(t.string()), + mode: t.option(t.string()), + amountTotal: t.option(t.i64()), + currency: t.option(t.string()), + customerId: t.option(t.string()), + paymentIntentId: t.option(t.string()), + message: t.option(t.string()), + code: t.option(t.string()), + errorType: t.option(t.string()), + }), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'GET', + path: `/v1/checkout/sessions/${args.sessionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + const parsed = safeJsonParse(response.body); + const isOk = response.status >= 200 && response.status < 300; + if (!isOk) { + const err = + isRecord(parsed) && isRecord(parsed.error) ? parsed.error : undefined; + return { + ok: false, + status: response.status, + sessionId: undefined, + paymentStatus: undefined, + sessionStatus: undefined, + mode: undefined, + amountTotal: undefined, + currency: undefined, + customerId: undefined, + paymentIntentId: undefined, + message: + maybeString(err?.message) ?? `Stripe returned ${response.status}.`, + code: maybeString(err?.code), + errorType: maybeString(err?.type), + }; + } + const data = isRecord(parsed) ? parsed : {}; + return { + ok: true, + status: response.status, + sessionId: maybeString(data.id) ?? args.sessionId, + paymentStatus: maybeString(data.payment_status), + sessionStatus: maybeString(data.status), + mode: maybeString(data.mode), + amountTotal: maybeBigIntFromUnknown(data.amount_total), + currency: maybeString(data.currency), + customerId: maybeId(data.customer), + paymentIntentId: maybeId(data.payment_intent), + message: undefined, + code: undefined, + errorType: undefined, + }; + } +); + +// Cheap count of stripe_webhook_event rows; exposes the metric without leaking payloads. +export const getWebhookEventCount = spacetimedb.procedure({}, t.i64(), ctx => + withAdminTx(ctx, tx => BigInt(tx.db.stripeWebhookEvent.count())) +); + +export const stripeApiRequest = spacetimedb.procedure( + { + method: t.string(), + path: t.string(), + formBody: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + stripeHttpResponse, + (ctx, args) => { + const verdict = ctx.withTx(tx => adminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + const cfg = loadConfigOrThrowFromProcedure(ctx); + return callStripe(ctx, { + method: args.method, + path: args.path, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + formBody: args.formBody, + idempotencyKey: args.idempotencyKey, + }); + } +); + +export const createCustomer = spacetimedb.procedure( + { + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + createCustomerResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const customerId = createCustomerInStripeAndSync(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + email: args.email, + name: args.name, + metadataJson: args.metadataJson, + idempotencyKey: args.idempotencyKey, + }); + return { customerId }; + } +); + +export const createOrUpdateCustomer = spacetimedb.procedure( + { + stripeCustomerId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + }, + t.string(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const details = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + upsertCustomerRow(tx, ctx.timestamp, { + stripeCustomerId: args.stripeCustomerId, + appUserId: undefined, + email: args.email, + name: args.name, + metadataJson: details.metadataJson, + userId: details.userId, + }); + }); + return args.stripeCustomerId; + } +); + +export const updateSubscriptionMetadata = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + metadataJson: t.string(), + orgId: t.option(t.string()), + userId: t.option(t.string()), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const parsedDetails = coerceMetadataFromJson(args.metadataJson); + ctx.withTx(tx => { + const existing = tx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + if (!existing) { + throwSenderError( + `stripe.subscription_not_found:${args.stripeSubscriptionId}` + ); + } + upsertSubscriptionRow(tx, ctx.timestamp, { + stripeSubscriptionId: existing.stripeSubscriptionId, + stripeCustomerId: existing.stripeCustomerId, + status: existing.status, + currentPeriodEndUnix: existing.currentPeriodEndUnix, + cancelAtPeriodEnd: existing.cancelAtPeriodEnd, + cancelAtUnix: existing.cancelAtUnix, + quantity: existing.quantity, + priceId: existing.priceId, + metadataJson: parsedDetails.metadataJson ?? args.metadataJson, + orgId: args.orgId ?? parsedDetails.orgId ?? existing.orgId, + userId: args.userId ?? parsedDetails.userId ?? existing.userId, + }); + }); + return {}; + } +); +export const getOrCreateCustomer = spacetimedb.procedure( + { + userId: t.string(), + email: t.option(t.string()), + name: t.option(t.string()), + }, + getOrCreateCustomerResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const existingByUser = ctx.withTx(tx => { + for (const customer of tx.db.stripeCustomer.byUserId.filter(args.userId)) + return customer; + return undefined; + }); + if (existingByUser) { + return { customerId: existingByUser.stripeCustomerId, isNew: false }; + } + + if (args.email) { + const existingByEmail = ctx.withTx(tx => { + for (const customer of tx.db.stripeCustomer.byEmail.filter(args.email)) + return customer; + return undefined; + }); + if (existingByEmail) { + return { customerId: existingByEmail.stripeCustomerId, isNew: false }; + } + } + + const existingSub = ctx.withTx(tx => { + for (const sub of tx.db.stripeSubscription.byUserId.filter(args.userId)) + return sub; + return undefined; + }); + if (existingSub) { + return { customerId: existingSub.stripeCustomerId, isNew: false }; + } + + const existingPayment = ctx.withTx(tx => { + for (const payment of tx.db.stripePayment.byUserId.filter(args.userId)) { + if (payment.userId === args.userId && payment.stripeCustomerId) + return payment; + } + return undefined; + }); + if (existingPayment?.stripeCustomerId) { + return { customerId: existingPayment.stripeCustomerId, isNew: false }; + } + + const cfg = loadConfigOrThrowFromProcedure(ctx); + const metadataJson = JSON.stringify({ userId: args.userId }); + const customerId = createCustomerInStripeAndSync(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + email: args.email, + name: args.name, + metadataJson, + idempotencyKey: args.userId, + }); + return { customerId, isNew: true }; + } +); + +// Stripe enforces one mode per session; all items must share mode. +export const createCheckoutSession = spacetimedb.procedure( + { + items: t.array( + t.object('CheckoutLineItem', { + priceId: t.string(), + quantity: t.i64(), + }) + ), + customerId: t.option(t.string()), + mode: t.string(), + successUrl: t.string(), + cancelUrl: t.string(), + metadataJson: t.option(t.string()), + subscriptionMetadataJson: t.option(t.string()), + paymentIntentMetadataJson: t.option(t.string()), + }, + checkoutSessionResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + if (args.items.length === 0) { + throwSenderError('stripe.checkout_session_requires_items'); + } + const formPairs: Array<[string, string | undefined]> = [ + ['mode', args.mode], + ['success_url', args.successUrl], + ['cancel_url', args.cancelUrl], + ['customer', args.customerId], + ]; + args.items.forEach((item, i) => { + formPairs.push([`line_items[${i}][price]`, item.priceId]); + formPairs.push([`line_items[${i}][quantity]`, String(item.quantity)]); + }); + for (const [k, v] of metadataJsonToFormPairs( + 'metadata', + args.metadataJson + )) { + formPairs.push([k, v]); + } + if (args.mode === 'subscription') { + for (const [k, v] of metadataJsonToFormPairs( + 'subscription_data[metadata]', + args.subscriptionMetadataJson + )) { + formPairs.push([k, v]); + } + } + if (args.mode === 'payment') { + for (const [k, v] of metadataJsonToFormPairs( + 'payment_intent_data[metadata]', + args.paymentIntentMetadataJson + )) { + formPairs.push([k, v]); + } + } + + const response = callStripe(ctx, { + method: 'POST', + path: '/v1/checkout/sessions', + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody(formPairs), + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.checkout_session_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + + const sessionResult = v.safeParse( + vStripeCheckoutSessionResponse, + safeJsonParse(response.body) + ); + if (!sessionResult.success) { + throwSenderError( + `stripe.checkout_session_invalid_response:${summarizeIssues(sessionResult.issues)}` + ); + } + return { + sessionId: sessionResult.output.id, + url: sessionResult.output.url ?? undefined, + }; + } +); + +export const createCustomerPortalSession = spacetimedb.procedure( + { + customerId: t.string(), + returnUrl: t.string(), + }, + portalSessionResult, + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const response = callStripe(ctx, { + method: 'POST', + path: '/v1/billing_portal/sessions', + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody([ + ['customer', args.customerId], + ['return_url', args.returnUrl], + ]), + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.portal_session_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + + const portalResult = v.safeParse( + vStripeBillingPortalSessionResponse, + safeJsonParse(response.body) + ); + if (!portalResult.success) { + throwSenderError( + `stripe.portal_session_invalid_response:${summarizeIssues(portalResult.issues)}` + ); + } + return { url: portalResult.output.url }; + } +); + +function patchSubscriptionFromStripe( + ctx: ProcedureModuleCtx, + args: { + secretKey: string; + stripeVersion: string | undefined; + stripeSubscriptionId: string; + formBody: string; + } +) { + const response = callStripe(ctx, { + method: 'POST', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: args.secretKey, + stripeVersion: args.stripeVersion, + idempotencyKey: undefined, + formBody: args.formBody, + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.subscription_update_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + const parsed = safeJsonParse(response.body); + if (!isRecord(parsed)) + throwSenderError('stripe.subscription_update_invalid_response'); + return parsed; +} + +function syncSubscriptionObjectFromStripe( + ctx: ProcedureModuleCtx, + stripeSubscription: JsonRecord +) { + const subscriptionId = maybeString(stripeSubscription.id); + const customerId = maybeId(stripeSubscription.customer); + const status = maybeString(stripeSubscription.status); + if (!subscriptionId || !customerId || !status) { + throwSenderError('stripe.subscription_payload_missing_fields'); + } + + const items = isRecord(stripeSubscription.items) + ? stripeSubscription.items + : undefined; + const firstItem = Array.isArray(items?.data) ? items.data[0] : undefined; + const first = isRecord(firstItem) ? firstItem : undefined; + const price = isRecord(first?.price) ? first.price : undefined; + + const currentPeriodEnd = + maybeBigIntFromUnknown(first?.current_period_end) ?? + maybeBigIntFromUnknown(stripeSubscription.current_period_end) ?? + 0n; + const cancelAtUnix = maybeBigIntFromUnknown(stripeSubscription.cancel_at); + const cancelAtPeriodEnd = + maybeBoolean(stripeSubscription.cancel_at_period_end) ?? + deriveCancelAtPeriodEnd(cancelAtUnix, currentPeriodEnd); + const quantity = maybeBigIntFromUnknown(first?.quantity); + const meta = metadataInfo(stripeSubscription.metadata); + + ctx.withTx(tx => { + upsertSubscriptionRow(tx, ctx.timestamp, { + stripeSubscriptionId: subscriptionId, + stripeCustomerId: customerId, + status, + currentPeriodEndUnix: currentPeriodEnd, + cancelAtPeriodEnd, + cancelAtUnix, + quantity, + priceId: maybeString(price?.id), + metadataJson: meta.metadataJson, + orgId: meta.orgId, + userId: meta.userId, + }); + }); +} + +export const cancelSubscription = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + cancelAtPeriodEnd: t.option(t.bool()), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const atPeriodEnd = args.cancelAtPeriodEnd ?? true; + const stripeSubscription = atPeriodEnd + ? patchSubscriptionFromStripe(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + stripeSubscriptionId: args.stripeSubscriptionId, + formBody: formPairsToBody([['cancel_at_period_end', 'true']]), + }) + : (() => { + const response = callStripe(ctx, { + method: 'DELETE', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + if (response.status < 200 || response.status >= 300) { + throwSenderError( + `stripe.subscription_cancel_failed:${response.status}${stripeErrorSuffix(response.body)}` + ); + } + const parsed = safeJsonParse(response.body); + if (!isRecord(parsed)) { + throwSenderError('stripe.subscription_cancel_invalid_response'); + } + return parsed; + })(); + + syncSubscriptionObjectFromStripe(ctx, stripeSubscription); + return {}; + } +); + +export const reactivateSubscription = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const stripeSubscription = patchSubscriptionFromStripe(ctx, { + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + stripeSubscriptionId: args.stripeSubscriptionId, + formBody: formPairsToBody([['cancel_at_period_end', 'false']]), + }); + syncSubscriptionObjectFromStripe(ctx, stripeSubscription); + return {}; + } +); + +export const updateSubscriptionQuantity = spacetimedb.procedure( + { + stripeSubscriptionId: t.string(), + quantity: t.i64(), + }, + t.unit(), + (ctx, args) => { + requireProcedureAdmin(ctx); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const getResponse = callStripe(ctx, { + method: 'GET', + path: `/v1/subscriptions/${args.stripeSubscriptionId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: undefined, + }); + if (getResponse.status < 200 || getResponse.status >= 300) { + throwSenderError( + `stripe.subscription_lookup_failed:${getResponse.status}${stripeErrorSuffix(getResponse.body)}` + ); + } + + const existing = safeJsonParse(getResponse.body); + if (!isRecord(existing)) + throwSenderError('stripe.subscription_lookup_invalid_response'); + const items = isRecord(existing.items) ? existing.items : undefined; + const firstItem = Array.isArray(items?.data) ? items.data[0] : undefined; + const firstRecord = isRecord(firstItem) ? firstItem : undefined; + const subscriptionItemId = maybeString(firstRecord?.id); + if (!subscriptionItemId) + throwSenderError('stripe.subscription_missing_line_items'); + + const updateResponse = callStripe(ctx, { + method: 'POST', + path: `/v1/subscription_items/${subscriptionItemId}`, + secretKey: cfg.secretKey, + stripeVersion: cfg.stripeVersion, + idempotencyKey: undefined, + formBody: formPairsToBody([['quantity', String(args.quantity)]]), + }); + if (updateResponse.status < 200 || updateResponse.status >= 300) { + throwSenderError( + `stripe.subscription_item_update_failed:${updateResponse.status}${stripeErrorSuffix(updateResponse.body)}` + ); + } + + ctx.withTx(tx => { + const localSub = tx.db.stripeSubscription.stripeSubscriptionId.find( + args.stripeSubscriptionId + ); + if (!localSub) return; + upsertSubscriptionRow(tx, ctx.timestamp, { + stripeSubscriptionId: localSub.stripeSubscriptionId, + stripeCustomerId: localSub.stripeCustomerId, + status: localSub.status, + currentPeriodEndUnix: localSub.currentPeriodEndUnix, + cancelAtPeriodEnd: localSub.cancelAtPeriodEnd, + cancelAtUnix: localSub.cancelAtUnix, + quantity: args.quantity, + priceId: localSub.priceId, + metadataJson: localSub.metadataJson, + orgId: localSub.orgId, + userId: localSub.userId, + }); + }); + return {}; + } +); diff --git a/spacetime-stripe-ts/src/submodule/operations/queries.ts b/spacetime-stripe-ts/src/submodule/operations/queries.ts new file mode 100644 index 00000000000..2ba0e57962a --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/queries.ts @@ -0,0 +1,205 @@ +import { + Range, + t, + spacetimedb, + stripeCustomerTable, + stripeSubscriptionTable, + stripeCheckoutSessionTable, + stripePaymentTable, + stripeInvoiceTable, + subscriptionWithCreationTime, +} from '../schema'; +import { withAdminTx, takeRows } from '../operations'; +import { latestSubscription } from '../subscription-order'; + +export const getCustomer = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeCustomer.stripeCustomerId.find(stripeCustomerId) ?? + undefined + ) +); + +export const getCustomerByEmail = spacetimedb.procedure( + { email: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { email }) => + withAdminTx(ctx, tx => { + for (const customer of tx.db.stripeCustomer.byEmail.filter(email)) + return customer; + return undefined; + }) +); + +export const getCustomerByUserId = spacetimedb.procedure( + { userId: t.string() }, + t.option(stripeCustomerTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => { + for (const customer of tx.db.stripeCustomer.byUserId.filter(userId)) + return customer; + return undefined; + }) +); + +export const getSubscription = spacetimedb.procedure( + { stripeSubscriptionId: t.string() }, + t.option(stripeSubscriptionTable.rowType), + (ctx, { stripeSubscriptionId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeSubscription.stripeSubscriptionId.find( + stripeSubscriptionId + ) ?? undefined + ) +); + +export const listSubscriptions = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeSubscription.byCustomer.filter(stripeCustomerId)) + ) +); + +export const listSubscriptionsWithCreationTime = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(subscriptionWithCreationTime), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byCustomerInsertedAt.filter([ + stripeCustomerId, + new Range(), + ]) + ).map(sub => ({ + insertedAtMicros: sub.insertedAt.microsSinceUnixEpoch, + stripeSubscriptionId: sub.stripeSubscriptionId, + stripeCustomerId: sub.stripeCustomerId, + status: sub.status, + })) + ) +); + +export const getSubscriptionByOrgId = spacetimedb.procedure( + { orgId: t.string() }, + t.option(stripeSubscriptionTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => + latestSubscription( + tx.db.stripeSubscription.byOrgInsertedAt.filter([orgId, new Range()]) + ) + ) +); + +export const listSubscriptionsByOrgId = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byOrgInsertedAt.filter([orgId, new Range()]) + ) + ) +); + +export const listSubscriptionsByUserId = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripeSubscriptionTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows( + tx.db.stripeSubscription.byUserInsertedAt.filter([userId, new Range()]) + ) + ) +); + +export const getPayment = spacetimedb.procedure( + { stripePaymentIntentId: t.string() }, + t.option(stripePaymentTable.rowType), + (ctx, { stripePaymentIntentId }) => + withAdminTx( + ctx, + tx => + tx.db.stripePayment.stripePaymentIntentId.find(stripePaymentIntentId) ?? + undefined + ) +); + +export const listPayments = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripePayment.byCustomer.filter(stripeCustomerId)) + ) +); + +export const listPaymentsByUserId = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripePayment.byUserId.filter(userId)) + ) +); + +export const listPaymentsByOrgId = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripePaymentTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => takeRows(tx.db.stripePayment.byOrgId.filter(orgId))) +); + +export const listInvoices = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeInvoice.byCustomer.filter(stripeCustomerId)) + ) +); + +export const listInvoicesByOrgId = spacetimedb.procedure( + { orgId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { orgId }) => + withAdminTx(ctx, tx => takeRows(tx.db.stripeInvoice.byOrgId.filter(orgId))) +); + +export const listInvoicesByUserId = spacetimedb.procedure( + { userId: t.string() }, + t.array(stripeInvoiceTable.rowType), + (ctx, { userId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeInvoice.byUserId.filter(userId)) + ) +); + +export const getCheckoutSession = spacetimedb.procedure( + { stripeCheckoutSessionId: t.string() }, + t.option(stripeCheckoutSessionTable.rowType), + (ctx, { stripeCheckoutSessionId }) => + withAdminTx( + ctx, + tx => + tx.db.stripeCheckoutSession.stripeCheckoutSessionId.find( + stripeCheckoutSessionId + ) ?? undefined + ) +); + +export const listCheckoutSessions = spacetimedb.procedure( + { stripeCustomerId: t.string() }, + t.array(stripeCheckoutSessionTable.rowType), + (ctx, { stripeCustomerId }) => + withAdminTx(ctx, tx => + takeRows(tx.db.stripeCheckoutSession.byCustomer.filter(stripeCustomerId)) + ) +); diff --git a/spacetime-stripe-ts/src/submodule/operations/webhook.ts b/spacetime-stripe-ts/src/submodule/operations/webhook.ts new file mode 100644 index 00000000000..ef6490c9e85 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/operations/webhook.ts @@ -0,0 +1,110 @@ +import { WebhookEventStatus, spacetimedb } from '../schema'; +import { + SyncResponse, + type HandlerContext, + type Request as StdbRequest, +} from 'spacetimedb/server'; +import { verifyStripeSignature } from '@spacetimedb/crypto'; +import { parseStripeEventMetadata } from '../webhook-metadata'; +import { MAX_WEBHOOK_METADATA_LENGTH } from '../limits'; +import { applyStripeEvent, updateWebhookStatus } from '../operations'; +import { + validateWebhookRequestBody, + validateWebhookRequestHeaders, +} from '../webhook-request'; + +// POST $STDB_URI/v1/database//route/stripe/webhook +function jsonResponse(status: number, body: unknown): SyncResponse { + return new SyncResponse(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +export function handleStripeWebhook( + ctx: HandlerContext, + req: StdbRequest +): SyncResponse { + const signatureHeader = req.headers.get('stripe-signature') ?? undefined; + const headerRejection = validateWebhookRequestHeaders( + req.method, + req.headers.get('content-length'), + signatureHeader + ); + if (headerRejection) { + return jsonResponse(headerRejection.status, { + error: headerRejection.error, + }); + } + + const payloadJson = req.text(); + const bodyRejection = validateWebhookRequestBody(payloadJson); + if (bodyRejection) { + return jsonResponse(bodyRejection.status, { error: bodyRejection.error }); + } + + // Webhook processing requires a configured signing secret. + const webhookSecret = ctx.withTx(tx => { + const cfg = tx.db.stripeConfig.singleton.find(true); + return cfg?.webhookSigningSecret ?? undefined; + }); + if (!webhookSecret) { + return jsonResponse(503, { + error: 'webhook signing secret not configured', + }); + } + const nowSeconds = Number(ctx.timestamp.microsSinceUnixEpoch / 1_000_000n); + const sigOk = verifyStripeSignature({ + rawBody: payloadJson, + signatureHeader: signatureHeader ?? '', + secret: webhookSecret, + nowSeconds, + }); + if (!sigOk) { + return jsonResponse(401, { error: 'signature mismatch' }); + } + + const metadata = parseStripeEventMetadata(payloadJson); + if (!metadata) + return jsonResponse(400, { error: 'missing or invalid event metadata' }); + const { eventId, eventType, livemode } = metadata; + if ( + eventId.length === 0 || + eventId.length > MAX_WEBHOOK_METADATA_LENGTH || + eventType.length === 0 || + eventType.length > MAX_WEBHOOK_METADATA_LENGTH + ) { + return jsonResponse(400, { error: 'invalid event metadata' }); + } + + try { + const outcome = ctx.withTx(tx => { + const existing = tx.db.stripeWebhookEvent.eventId.find(eventId); + if (existing) return { kind: 'duplicate', status: existing.status }; + + tx.db.stripeWebhookEvent.insert({ + eventId, + eventType, + livemode, + signatureHeader, + payloadJson, + status: WebhookEventStatus.Received, + errorMessage: undefined, + receivedAt: tx.timestamp, + processedAt: undefined, + }); + + const result = applyStripeEvent(tx, payloadJson); + updateWebhookStatus(tx, eventId, result.status, result.error); + return { kind: 'applied', status: result.status, error: result.error }; + }); + + return jsonResponse(200, { ok: true, eventId, ...outcome }); + } catch (error) { + console.error(`stripe webhook processing failed for ${eventId}:`, error); + return jsonResponse(500, { error: 'webhook processing failed' }); + } +} + +export const stripeWebhookHandler = + spacetimedb.httpHandler(handleStripeWebhook); diff --git a/spacetime-stripe-ts/src/submodule/router.ts b/spacetime-stripe-ts/src/submodule/router.ts new file mode 100644 index 00000000000..091484587a9 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/router.ts @@ -0,0 +1,7 @@ +import { Router } from 'spacetimedb/server'; +import { spacetimedb } from './schema'; +import { stripeWebhookHandler } from './operations/webhook'; + +export const stripeWebhookRouter = spacetimedb.httpRouter( + new Router().post('/stripe/webhook', stripeWebhookHandler) +); diff --git a/spacetime-stripe-ts/src/submodule/schema.ts b/spacetime-stripe-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..d4188f9a232 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/schema.ts @@ -0,0 +1,457 @@ +import { + schema, + table, + t, + Range, + SenderError, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, +} from 'spacetimedb/server'; +import * as v from 'valibot'; +import { installStripe } from './install'; + +// Internal ingest lifecycle for webhook rows. Received = stored. Processed = +// applied to the data model. Ignored = duplicate or unhandled event type. +// Failed = signature/format error. +// +// The other status columns on this schema (subscription, checkout, invoice, +// and payment) stay as t.string() because they reflect Stripe-owned vocabulary +// delivered by webhooks. An open string preserves new provider states. +// Stripe's TS types lift them to literal unions on the SDK side; consumers +// can do `subscription.status === 'active'` directly against the wire value. +export const webhookEventStatus = t.enum('WebhookEventStatus', [ + 'Received', + 'Processed', + 'Ignored', + 'Failed', +]); +export const WebhookEventStatus = { + Received: { tag: 'Received' as const }, + Processed: { tag: 'Processed' as const }, + Ignored: { tag: 'Ignored' as const }, + Failed: { tag: 'Failed' as const }, +}; +export type WebhookEventStatusValue = + (typeof WebhookEventStatus)[keyof typeof WebhookEventStatus]; + +export const stripeCustomerRow = { + stripeCustomerId: t.string().primaryKey(), + appUserId: t.option(t.string()), + email: t.option(t.string()), + name: t.option(t.string()), + metadataJson: t.option(t.string()), + userId: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeSubscriptionRow = { + stripeSubscriptionId: t.string().primaryKey(), + stripeCustomerId: t.string(), + status: t.string(), + currentPeriodEndUnix: t.i64(), + cancelAtPeriodEnd: t.bool(), + cancelAtUnix: t.option(t.i64()), + quantity: t.option(t.i64()), + priceId: t.option(t.string()), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeCheckoutSessionRow = { + stripeCheckoutSessionId: t.string().primaryKey(), + stripeCustomerId: t.option(t.string()), + status: t.string(), + mode: t.string(), + metadataJson: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripePaymentRow = { + stripePaymentIntentId: t.string().primaryKey(), + stripeCustomerId: t.option(t.string()), + amount: t.i64(), + currency: t.string(), + status: t.string(), + createdUnix: t.i64(), + metadataJson: t.option(t.string()), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeInvoiceRow = { + stripeInvoiceId: t.string().primaryKey(), + stripeCustomerId: t.string(), + stripeSubscriptionId: t.option(t.string()), + status: t.string(), + amountDue: t.i64(), + amountPaid: t.i64(), + createdUnix: t.i64(), + orgId: t.option(t.string()), + userId: t.option(t.string()), + insertedAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const stripeWebhookEventRow = { + eventId: t.string().primaryKey(), + eventType: t.string(), + livemode: t.bool(), + signatureHeader: t.option(t.string()), + payloadJson: t.string(), + status: webhookEventStatus, + errorMessage: t.option(t.string()), + receivedAt: t.timestamp(), + processedAt: t.option(t.timestamp()), +}; + +export const stripeCustomerTable = table( + { + name: 'stripe_customer', + public: false, + indexes: [ + { accessor: 'byEmail', algorithm: 'btree', columns: ['email'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripeCustomerRow +); +export const stripeSubscriptionTable = table( + { + name: 'stripe_subscription', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { + accessor: 'byCustomerInsertedAt', + algorithm: 'btree', + columns: ['stripeCustomerId', 'insertedAt'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { + accessor: 'byOrgInsertedAt', + algorithm: 'btree', + columns: ['orgId', 'insertedAt'], + }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + { + accessor: 'byUserInsertedAt', + algorithm: 'btree', + columns: ['userId', 'insertedAt'], + }, + ], + }, + stripeSubscriptionRow +); +export const stripeCheckoutSessionTable = table( + { + name: 'stripe_checkout_session', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + ], + }, + stripeCheckoutSessionRow +); +export const stripePaymentTable = table( + { + name: 'stripe_payment', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripePaymentRow +); +export const stripeInvoiceTable = table( + { + name: 'stripe_invoice', + public: false, + indexes: [ + { + accessor: 'byCustomer', + algorithm: 'btree', + columns: ['stripeCustomerId'], + }, + { + accessor: 'bySubscription', + algorithm: 'btree', + columns: ['stripeSubscriptionId'], + }, + { accessor: 'byOrgId', algorithm: 'btree', columns: ['orgId'] }, + { accessor: 'byUserId', algorithm: 'btree', columns: ['userId'] }, + ], + }, + stripeInvoiceRow +); +export const stripeWebhookEventTable = table( + { + name: 'stripe_webhook_event', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + ], + }, + stripeWebhookEventRow +); + +// Singleton row holding deploy-time secrets. Private, never subscribable. +export const stripeConfigRow = { + singleton: t.bool().primaryKey(), + secretKey: t.string(), + stripeVersion: t.option(t.string()), + webhookSigningSecret: t.option(t.string()), + updatedAt: t.timestamp(), +}; + +export const stripeConfigTable = table( + { name: 'stripe_config', public: false, indexes: [] }, + stripeConfigRow +); + +// Allowlist of identities permitted to call privileged procedures. +export const stripeAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +export const stripeAdminIdentityTable = table( + { name: 'stripe_admin_identity', public: false, indexes: [] }, + stripeAdminIdentityRow +); + +export const spacetimedb = schema({ + stripeCustomer: stripeCustomerTable, + stripeSubscription: stripeSubscriptionTable, + stripeCheckoutSession: stripeCheckoutSessionTable, + stripePayment: stripePaymentTable, + stripeInvoice: stripeInvoiceTable, + stripeWebhookEvent: stripeWebhookEventTable, + stripeConfig: stripeConfigTable, + stripeAdminIdentity: stripeAdminIdentityTable, +}); + +export const init = spacetimedb.init(ctx => { + installStripe(ctx); +}); + +export default spacetimedb; + +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx< + typeof spacetimedb.schemaType +>; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; +export type JsonRecord = Record; +export type ModuleTimestamp = ReducerModuleCtx['timestamp']; + +export const stripeHttpResponse = t.object('StripeHttpResponse', { + status: t.u16(), + body: t.string(), +}); + +export const checkoutSessionResult = t.object('CheckoutSessionResult', { + sessionId: t.string(), + url: t.option(t.string()), +}); + +export const createCustomerResult = t.object('CreateCustomerResult', { + customerId: t.string(), +}); + +export const getOrCreateCustomerResult = t.object('GetOrCreateCustomerResult', { + customerId: t.string(), + isNew: t.bool(), +}); + +export const portalSessionResult = t.object('PortalSessionResult', { + url: t.string(), +}); + +export const subscriptionWithCreationTime = t.object( + 'SubscriptionWithCreationTime', + { + insertedAtMicros: t.i64(), + stripeSubscriptionId: t.string(), + stripeCustomerId: t.string(), + status: t.string(), + } +); + +export { Range, SenderError, t }; + +const vMetadata = v.optional( + v.union([v.record(v.string(), v.string()), v.null()]) +); + +// Stripe "expandable" fields are either a string ID or an object with an id. +const vExpandableId = v.union([v.string(), v.object({ id: v.string() })]); +const vExpandableIdOrNull = v.union([ + v.string(), + v.object({ id: v.string() }), + v.null(), +]); + +export type ExpandableId = v.InferOutput; +export type ExpandableIdOrNull = v.InferOutput; + +export function extractExpandableId(value: ExpandableId): string { + return typeof value === 'string' ? value : value.id; +} + +export function extractExpandableIdOrNull( + value: ExpandableIdOrNull +): string | null { + if (value === null) return null; + return typeof value === 'string' ? value : value.id; +} + +const vCustomerObject = v.object({ + id: v.string(), + email: v.optional(v.union([v.string(), v.null()])), + name: v.optional(v.union([v.string(), v.null()])), + metadata: vMetadata, +}); + +const vSubscriptionItem = v.object({ + current_period_end: v.optional(v.number()), + quantity: v.optional(v.number()), + price: v.optional(v.union([v.object({ id: v.string() }), v.null()])), +}); + +const vSubscriptionObject = v.object({ + id: v.string(), + customer: vExpandableId, + status: v.string(), + current_period_end: v.optional(v.number()), + cancel_at: v.optional(v.union([v.number(), v.null()])), + cancel_at_period_end: v.optional(v.boolean()), + items: v.optional(v.object({ data: v.array(vSubscriptionItem) })), + metadata: vMetadata, +}); + +const vCheckoutSessionObject = v.object({ + id: v.string(), + mode: v.optional(v.string()), + customer: v.optional(vExpandableIdOrNull), + metadata: vMetadata, +}); + +const vInvoiceObject = v.object({ + id: v.string(), + // Stripe.Invoice.customer is nullable; the apply function rejects null. + customer: vExpandableIdOrNull, + subscription: v.optional(vExpandableIdOrNull), + status: v.optional(v.union([v.string(), v.null()])), + amount_due: v.optional(v.number()), + amount_paid: v.optional(v.number()), + created: v.optional(v.number()), +}); + +const vPaymentIntentObject = v.object({ + id: v.string(), + customer: v.optional(vExpandableIdOrNull), + invoice: v.optional(vExpandableIdOrNull), + amount: v.optional(v.number()), + currency: v.optional(v.string()), + status: v.optional(v.string()), + created: v.optional(v.number()), + metadata: vMetadata, +}); + +// Discriminated union over the 12 handled event types; unknown types route to status=failed. +export const vStripeEvent = v.variant('type', [ + v.object({ + type: v.literal('customer.created'), + data: v.object({ object: vCustomerObject }), + }), + v.object({ + type: v.literal('customer.updated'), + data: v.object({ object: vCustomerObject }), + }), + v.object({ + type: v.literal('customer.subscription.created'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('customer.subscription.updated'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('customer.subscription.deleted'), + data: v.object({ object: vSubscriptionObject }), + }), + v.object({ + type: v.literal('checkout.session.completed'), + data: v.object({ object: vCheckoutSessionObject }), + }), + v.object({ + type: v.literal('invoice.created'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.finalized'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.paid'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.payment_succeeded'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('invoice.payment_failed'), + data: v.object({ object: vInvoiceObject }), + }), + v.object({ + type: v.literal('payment_intent.succeeded'), + data: v.object({ object: vPaymentIntentObject }), + }), +]); + +export type ParsedStripeEvent = v.InferOutput; + +export const vStripeIdResponse = v.object({ id: v.string() }); + +export const vStripeCheckoutSessionResponse = v.object({ + id: v.string(), + url: v.optional(v.union([v.string(), v.null()])), +}); + +export const vStripeBillingPortalSessionResponse = v.object({ + url: v.string(), +}); + +// Stripe error response: `{ error: { type, code, message, request_log_url } }`. +export const vStripeErrorBody = v.object({ + error: v.object({ + type: v.optional(v.string()), + code: v.optional(v.string()), + message: v.optional(v.string()), + request_log_url: v.optional(v.union([v.string(), v.null()])), + }), +}); diff --git a/spacetime-stripe-ts/src/submodule/subscription-order.ts b/spacetime-stripe-ts/src/submodule/subscription-order.ts new file mode 100644 index 00000000000..ad2ee14ec44 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/subscription-order.ts @@ -0,0 +1,21 @@ +import type { Timestamp } from 'spacetimedb'; + +export function latestSubscription< + T extends { insertedAt: Timestamp; stripeSubscriptionId: string }, +>(rows: Iterable): T | undefined { + let latest: T | undefined; + // ponytail: scan the org's full history; add a newest-first index if this becomes costly. + for (const row of rows) { + if ( + !latest || + row.insertedAt.microsSinceUnixEpoch > + latest.insertedAt.microsSinceUnixEpoch || + (row.insertedAt.microsSinceUnixEpoch === + latest.insertedAt.microsSinceUnixEpoch && + row.stripeSubscriptionId > latest.stripeSubscriptionId) + ) { + latest = row; + } + } + return latest; +} diff --git a/spacetime-stripe-ts/src/submodule/validation.ts b/spacetime-stripe-ts/src/submodule/validation.ts new file mode 100644 index 00000000000..181f0f5e9ad --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/validation.ts @@ -0,0 +1,32 @@ +import * as v from 'valibot'; +import { SenderError } from 'spacetimedb/server'; + +export function assertExhaustive(value: never): never { + throw new Error(`Unhandled discriminant: ${value as string}`); +} + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function safeJsonParse(input: string): unknown { + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + +export function summarizeIssues(issues: v.BaseIssue[]): string { + if (issues.length === 0) return 'no issues'; + const head = issues[0]!; + const path = (head.path ?? []) + .map(p => + typeof p.key === 'string' || typeof p.key === 'number' + ? String(p.key) + : '?' + ) + .join('.'); + const where = path ? ` at ${path}` : ''; + return `${head.message}${where}`; +} diff --git a/spacetime-stripe-ts/src/submodule/webhook-metadata.ts b/spacetime-stripe-ts/src/submodule/webhook-metadata.ts new file mode 100644 index 00000000000..e4bb0db42e9 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/webhook-metadata.ts @@ -0,0 +1,25 @@ +export type StripeEventMetadata = { + eventId: string; + eventType: string; + livemode: boolean; +}; + +export function parseStripeEventMetadata( + payloadJson: string +): StripeEventMetadata | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(payloadJson); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const record = parsed as Record; + if (typeof record.id !== 'string' || typeof record.type !== 'string') + return undefined; + return { + eventId: record.id, + eventType: record.type, + livemode: typeof record.livemode === 'boolean' ? record.livemode : false, + }; +} diff --git a/spacetime-stripe-ts/src/submodule/webhook-request.ts b/spacetime-stripe-ts/src/submodule/webhook-request.ts new file mode 100644 index 00000000000..a55df3f1df8 --- /dev/null +++ b/spacetime-stripe-ts/src/submodule/webhook-request.ts @@ -0,0 +1,35 @@ +import { MAX_WEBHOOK_BODY_LENGTH, MAX_WEBHOOK_HEADER_LENGTH } from './limits'; + +export type WebhookRequestRejection = { + status: number; + error: string; +}; + +export function validateWebhookRequestHeaders( + method: string, + contentLengthHeader: string | null, + signatureHeader: string | undefined +): WebhookRequestRejection | undefined { + if (method !== 'POST') return { status: 405, error: 'method not allowed' }; + const contentLength = Number(contentLengthHeader ?? '0'); + if ( + Number.isFinite(contentLength) && + contentLength > MAX_WEBHOOK_BODY_LENGTH + ) { + return { status: 413, error: 'payload too large' }; + } + if ((signatureHeader?.length ?? 0) > MAX_WEBHOOK_HEADER_LENGTH) { + return { status: 431, error: 'signature header too large' }; + } + return undefined; +} + +export function validateWebhookRequestBody( + payloadJson: string +): WebhookRequestRejection | undefined { + if (payloadJson.length === 0) return { status: 400, error: 'empty body' }; + if (new TextEncoder().encode(payloadJson).length > MAX_WEBHOOK_BODY_LENGTH) { + return { status: 413, error: 'payload too large' }; + } + return undefined; +} diff --git a/spacetime-stripe-ts/tsconfig.json b/spacetime-stripe-ts/tsconfig.json new file mode 100644 index 00000000000..c659d97428a --- /dev/null +++ b/spacetime-stripe-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +}