From 8ad438ed44a657de9006199ec212af7f7ba68312 Mon Sep 17 00:00:00 2001
From: Dev XO
Date: Sat, 15 Jul 2023 21:19:59 +0200
Subject: [PATCH] v3.2.0
---
.dockerignore | 7 +
.env.example | 27 ++
.eslintrc.js | 7 +
.github/dependabot.yml | 20 +
.github/workflows/deploy.yml | 186 ++++++++++
.gitignore | 28 ++
.npmrc | 2 +
.prettierignore | 7 +
.prettierrc | 14 +
Dockerfile | 58 +++
LICENSE | 21 ++
README.md | 189 ++++++++++
app/components/footer.tsx | 55 +++
app/components/navigation.tsx | 114 ++++++
app/components/stripe/checkout-button.tsx | 57 +++
.../stripe/customer-portal-button.tsx | 16 +
app/entry.client.tsx | 22 ++
app/entry.server.tsx | 109 ++++++
app/models/plan/create-plan.ts | 8 +
app/models/plan/delete-plan.ts | 8 +
app/models/plan/get-plan.ts | 21 ++
app/models/plan/update-plan.ts | 9 +
.../subscription/create-subscription.ts | 10 +
.../subscription/delete-subscription.ts | 8 +
app/models/subscription/get-subscription.ts | 14 +
.../subscription/update-subscription.ts | 22 ++
app/models/user/create-user.ts | 8 +
app/models/user/delete-user.ts | 8 +
app/models/user/get-user.ts | 22 ++
app/models/user/update-user.ts | 9 +
app/root.css | 122 ++++++
app/root.tsx | 77 ++++
app/routes/_layout+/_index.tsx | 140 +++++++
app/routes/_layout+/_layout.tsx | 45 +++
app/routes/_layout+/account.tsx | 198 ++++++++++
app/routes/_layout+/checkout.tsx | 118 ++++++
app/routes/_layout+/login.email.tsx | 165 +++++++++
app/routes/_layout+/login.index.tsx | 40 ++
app/routes/_layout+/login.tsx | 54 +++
app/routes/_layout+/plans.tsx | 180 +++++++++
app/routes/_layout+/register.name.tsx | 104 ++++++
app/routes/_layout+/register.tsx | 20 +
app/routes/_layout+/support.tsx | 65 ++++
app/routes/api+/healthcheck.ts | 26 ++
app/routes/api+/webhook.ts | 138 +++++++
app/routes/auth+/$provider.callback.tsx | 19 +
app/routes/auth+/$provider.tsx | 19 +
app/routes/auth+/logout.tsx | 14 +
app/routes/auth+/magic.tsx | 17 +
.../resources+/stripe.create-checkout.ts | 47 +++
.../stripe.create-customer-portal.ts | 30 ++
.../resources+/stripe.create-customer.ts | 30 ++
.../resources+/stripe.create-subscription.ts | 57 +++
app/routes/resources+/user.delete.ts | 37 ++
app/services/auth/config.server.ts | 141 +++++++
app/services/auth/session.server.ts | 14 +
app/services/email/config.server.ts | 29 ++
.../stripe/api/configure-customer-portal.ts | 34 ++
app/services/stripe/api/create-checkout.ts | 27 ++
.../stripe/api/create-customer-portal.ts | 17 +
app/services/stripe/api/create-customer.ts | 7 +
app/services/stripe/api/create-price.ts | 24 ++
app/services/stripe/api/create-product.ts | 18 +
.../stripe/api/create-subscription.ts | 18 +
app/services/stripe/api/delete-customer.ts | 9 +
.../stripe/api/retrieve-subscription.ts | 11 +
app/services/stripe/config.server.ts | 8 +
app/services/stripe/plans.ts | 110 ++++++
app/utils/date.ts | 20 +
app/utils/db.ts | 17 +
app/utils/envs.ts | 49 +++
app/utils/hooks.ts | 24 ++
app/utils/http.ts | 4 +
app/utils/locales.ts | 11 +
fly.toml | 52 +++
package.json | 63 ++++
playwright.config.ts | 83 +++++
postcss.config.js | 6 +
prisma/schema.prisma | 102 +++++
prisma/seed.ts | 95 +++++
prisma/seed/app/models/plan/get-plan.js | 23 ++
.../stripe/api/configure-customer-portal.js | 30 ++
.../app/services/stripe/api/create-price.js | 20 +
.../app/services/stripe/api/create-product.js | 15 +
.../seed/app/services/stripe/config.server.js | 13 +
prisma/seed/app/services/stripe/plans.js | 59 +++
prisma/seed/app/utils/db.js | 15 +
prisma/seed/prisma/seed.js | 79 ++++
public/favicon.ico | Bin 0 -> 16958 bytes
remix.config.js | 25 ++
remix.env.d.ts | 2 +
remix.init/.gitignore | 7 +
remix.init/index.js | 347 ++++++++++++++++++
remix.init/lib/postgres/.env.example | 27 ++
remix.init/lib/postgres/Dockerfile | 53 +++
remix.init/lib/postgres/deploy.yml | 186 ++++++++++
remix.init/lib/postgres/docker-compose.yml | 13 +
remix.init/lib/postgres/fly.toml | 52 +++
remix.init/package.json | 14 +
start.sh | 8 +
tailwind.config.js | 10 +
tests/e2e/app.spec.ts | 8 +
tsconfig.json | 21 ++
tsconfig.seed.json | 18 +
104 files changed, 4886 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .eslintrc.js
create mode 100644 .github/dependabot.yml
create mode 100644 .github/workflows/deploy.yml
create mode 100644 .gitignore
create mode 100644 .npmrc
create mode 100644 .prettierignore
create mode 100644 .prettierrc
create mode 100644 Dockerfile
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 app/components/footer.tsx
create mode 100644 app/components/navigation.tsx
create mode 100644 app/components/stripe/checkout-button.tsx
create mode 100644 app/components/stripe/customer-portal-button.tsx
create mode 100644 app/entry.client.tsx
create mode 100644 app/entry.server.tsx
create mode 100644 app/models/plan/create-plan.ts
create mode 100644 app/models/plan/delete-plan.ts
create mode 100644 app/models/plan/get-plan.ts
create mode 100644 app/models/plan/update-plan.ts
create mode 100644 app/models/subscription/create-subscription.ts
create mode 100644 app/models/subscription/delete-subscription.ts
create mode 100644 app/models/subscription/get-subscription.ts
create mode 100644 app/models/subscription/update-subscription.ts
create mode 100644 app/models/user/create-user.ts
create mode 100644 app/models/user/delete-user.ts
create mode 100644 app/models/user/get-user.ts
create mode 100644 app/models/user/update-user.ts
create mode 100644 app/root.css
create mode 100644 app/root.tsx
create mode 100644 app/routes/_layout+/_index.tsx
create mode 100644 app/routes/_layout+/_layout.tsx
create mode 100644 app/routes/_layout+/account.tsx
create mode 100644 app/routes/_layout+/checkout.tsx
create mode 100644 app/routes/_layout+/login.email.tsx
create mode 100644 app/routes/_layout+/login.index.tsx
create mode 100644 app/routes/_layout+/login.tsx
create mode 100644 app/routes/_layout+/plans.tsx
create mode 100644 app/routes/_layout+/register.name.tsx
create mode 100644 app/routes/_layout+/register.tsx
create mode 100644 app/routes/_layout+/support.tsx
create mode 100644 app/routes/api+/healthcheck.ts
create mode 100644 app/routes/api+/webhook.ts
create mode 100644 app/routes/auth+/$provider.callback.tsx
create mode 100644 app/routes/auth+/$provider.tsx
create mode 100644 app/routes/auth+/logout.tsx
create mode 100644 app/routes/auth+/magic.tsx
create mode 100644 app/routes/resources+/stripe.create-checkout.ts
create mode 100644 app/routes/resources+/stripe.create-customer-portal.ts
create mode 100644 app/routes/resources+/stripe.create-customer.ts
create mode 100644 app/routes/resources+/stripe.create-subscription.ts
create mode 100644 app/routes/resources+/user.delete.ts
create mode 100644 app/services/auth/config.server.ts
create mode 100644 app/services/auth/session.server.ts
create mode 100644 app/services/email/config.server.ts
create mode 100644 app/services/stripe/api/configure-customer-portal.ts
create mode 100644 app/services/stripe/api/create-checkout.ts
create mode 100644 app/services/stripe/api/create-customer-portal.ts
create mode 100644 app/services/stripe/api/create-customer.ts
create mode 100644 app/services/stripe/api/create-price.ts
create mode 100644 app/services/stripe/api/create-product.ts
create mode 100644 app/services/stripe/api/create-subscription.ts
create mode 100644 app/services/stripe/api/delete-customer.ts
create mode 100644 app/services/stripe/api/retrieve-subscription.ts
create mode 100644 app/services/stripe/config.server.ts
create mode 100644 app/services/stripe/plans.ts
create mode 100644 app/utils/date.ts
create mode 100644 app/utils/db.ts
create mode 100644 app/utils/envs.ts
create mode 100644 app/utils/hooks.ts
create mode 100644 app/utils/http.ts
create mode 100644 app/utils/locales.ts
create mode 100644 fly.toml
create mode 100644 package.json
create mode 100644 playwright.config.ts
create mode 100644 postcss.config.js
create mode 100644 prisma/schema.prisma
create mode 100644 prisma/seed.ts
create mode 100644 prisma/seed/app/models/plan/get-plan.js
create mode 100644 prisma/seed/app/services/stripe/api/configure-customer-portal.js
create mode 100644 prisma/seed/app/services/stripe/api/create-price.js
create mode 100644 prisma/seed/app/services/stripe/api/create-product.js
create mode 100644 prisma/seed/app/services/stripe/config.server.js
create mode 100644 prisma/seed/app/services/stripe/plans.js
create mode 100644 prisma/seed/app/utils/db.js
create mode 100644 prisma/seed/prisma/seed.js
create mode 100644 public/favicon.ico
create mode 100644 remix.config.js
create mode 100644 remix.env.d.ts
create mode 100644 remix.init/.gitignore
create mode 100644 remix.init/index.js
create mode 100644 remix.init/lib/postgres/.env.example
create mode 100644 remix.init/lib/postgres/Dockerfile
create mode 100644 remix.init/lib/postgres/deploy.yml
create mode 100644 remix.init/lib/postgres/docker-compose.yml
create mode 100644 remix.init/lib/postgres/fly.toml
create mode 100644 remix.init/package.json
create mode 100755 start.sh
create mode 100644 tailwind.config.js
create mode 100644 tests/e2e/app.spec.ts
create mode 100644 tsconfig.json
create mode 100644 tsconfig.seed.json
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..709870d9
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+/node_modules
+*.log
+.DS_Store
+.env
+/.cache
+/public/build
+/build
\ No newline at end of file
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..a8a2acb3
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,27 @@
+# Base.
+NODE_ENV="development"
+SESSION_SECRET="SESSION_SECRET"
+ENCRYPTION_SECRET="ENCRYPTION_SECRET"
+
+# Host.
+DEV_HOST_URL="http://localhost:3000"
+PROD_HOST_URL="https://example.com"
+
+# Environment variables declared in this file are automatically made available to Prisma.
+# More details: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+DATABASE_URL="file:./data.db?connection_limit=1"
+
+# [Optional] Social Authentification.
+GOOGLE_CLIENT_ID=""
+GOOGLE_CLIENT_SECRET=""
+
+# [Optional] Email Provider.
+EMAIL_PROVIDER_API_KEY=""
+
+# Stripe.
+STRIPE_PUBLIC_KEY="STRIPE_PUBLIC_KEY"
+STRIPE_SECRET_KEY="STRIPE_SECRET_KEY"
+
+# Stripe Webhook.
+DEV_STRIPE_WEBHOOK_ENDPOINT=""
+PROD_STRIPE_WEBHOOK_ENDPOINT=""
\ No newline at end of file
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 00000000..dba78373
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,7 @@
+/**
+ * @type {import('eslint').Linter.Config}
+ */
+module.exports = {
+ extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node', 'prettier'],
+ ignorePatterns: ['build'],
+}
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..1db57d5c
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,20 @@
+# Learn about Dependabot:
+# https://docs.github.com/en/code-security/dependabot
+
+version: 2
+updates:
+ # Enable version updates for npm.
+ - package-ecosystem: 'npm'
+ # Look for `package.json` and `lock` files in the `root` directory.
+ directory: '/'
+ # Check the npm registry for updates every day.
+ schedule:
+ interval: 'daily'
+
+ # Enable version updates for Github-Actions.
+ - package-ecosystem: github-actions
+ # Look in the `root` directory.
+ directory: /
+ # Check for updates every day (weekdays)
+ schedule:
+ interval: daily
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..f9b9f9f1
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,186 @@
+name: ๐ Deploy
+on:
+ push:
+ branches:
+ - main
+ - dev
+ pull_request: {}
+
+permissions:
+ actions: write
+ contents: read
+
+jobs:
+ lint:
+ name: โฌฃ ESLint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Setup Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+
+ - name: Install Dependencies
+ uses: bahmutov/npm-install@v1
+ with:
+ useLockFile: false
+
+ - name: Run Lint
+ run: npm run lint
+
+ typecheck:
+ name: สฆ TypeScript
+ runs-on: ubuntu-latest
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Setup Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+
+ - name: Install Dependencies
+ uses: bahmutov/npm-install@v1
+ with:
+ useLockFile: false
+
+ - name: Run Typechecking
+ run: npm run typecheck --if-present
+
+ playwright:
+ name: ๐ญ Playwright
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Copy Environment Variables
+ run: cp .env.example .env
+
+ - name: Setup Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+
+ - name: Install Dependencies
+ uses: bahmutov/npm-install@v1
+ with:
+ useLockFile: false
+
+ - name: Install Playwright Browsers
+ run: npx playwright install --with-deps
+
+ - name: Setup Database
+ run: npx prisma migrate reset --force --skip-seed
+
+ - name: Build
+ run: npm run build
+
+ - name: Run Playwright Tests
+ run: npx playwright test
+
+ - name: Upload Report
+ uses: actions/upload-artifact@v3
+ if: always()
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 30
+
+ build:
+ name: ๐ณ Build
+ # Only build / deploy main branch on pushes.
+ if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Read App Name
+ uses: SebRollen/toml-action@v1.0.2
+ id: app_name
+ with:
+ file: 'fly.toml'
+ field: 'app'
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+ with:
+ version: v0.9.1
+
+ - name: Cache Docker Layers
+ uses: actions/cache@v3
+ with:
+ path: /tmp/.buildx-cache
+ key: ${{ runner.os }}-buildx-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-buildx-
+
+ - name: Fly Registry Auth
+ uses: docker/login-action@v2
+ with:
+ registry: registry.fly.io
+ username: x
+ password: ${{ secrets.FLY_API_TOKEN }}
+
+ - name: Docker build
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ push: true
+ tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}
+ build-args: |
+ COMMIT_SHA=${{ github.sha }}
+ cache-from: type=local,src=/tmp/.buildx-cache
+ cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+
+ - name: Move Cache
+ run: |
+ rm -rf /tmp/.buildx-cache
+ mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+
+ deploy:
+ name: ๐ Deploy
+ runs-on: ubuntu-latest
+ needs: [lint, typecheck, playwright, build]
+ # Only build / deploy main branch on pushes.
+ if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
+
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Read App Name
+ uses: SebRollen/toml-action@v1.0.2
+ id: app_name
+ with:
+ file: 'fly.toml'
+ field: 'app'
+
+ - name: Deploy Production
+ if: ${{ github.ref == 'refs/heads/main' }}
+ uses: superfly/flyctl-actions@1.3
+ with:
+ args: 'deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}'
+ env:
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..e2108fd1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+# Package Managers.
+package-lock.json
+yarn.lock
+pnpm-lock.yaml
+pnpm-lock.yml
+node_modules
+
+# Editor Configs.
+.idea
+.vscode
+.DS_Store
+
+# Miscelaneous.
+/.cache
+/build
+/public/build
+.env
+
+# Tests.
+/coverage
+
+# Prisma.
+/prisma/data.db
+/prisma/data.db-journal
+/prisma/migrations
+
+# Docker PostgreSQL.
+postgres-data
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 00000000..8051a481
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+auto-install-peers=true
+strict-peer-dependencies=false
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..08f7cc3c
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,7 @@
+node_modules
+
+/build
+/public/build
+.env
+
+/app/styles/tailwind.css
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..bbb278a7
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,14 @@
+{
+ "tabWidth": 2,
+ "printWidth": 90,
+ "semi": false,
+ "useTabs": false,
+ "bracketSpacing": true,
+ "bracketSameLine": true,
+ "singleQuote": true,
+ "jsxSingleQuote": false,
+ "singleAttributePerLine": false,
+ "arrowParens": "always",
+ "trailingComma": "all",
+ "plugins": ["prettier-plugin-tailwindcss"]
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..8c6dfb1f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,58 @@
+# Base node image.
+FROM node:16-bullseye-slim as base
+
+# Set global environment variables.
+ENV PORT="8080"
+ENV NODE_ENV="production"
+ENV DATABASE_URL=file:/data/sqlite.db
+
+# Install openssl for Prisma.
+RUN apt-get update && apt-get install -y sqlite3
+
+# Install all node_modules, including dev dependencies.
+FROM base as deps
+
+WORKDIR /myapp
+
+ADD package.json ./
+RUN npm install --production=false
+
+# Setup production node_modules.
+FROM base as production-deps
+
+WORKDIR /myapp
+COPY --from=deps /myapp/node_modules /myapp/node_modules
+
+ADD package.json ./
+RUN npm prune --production
+
+# Build the app.
+FROM base as build
+
+WORKDIR /myapp
+COPY --from=deps /myapp/node_modules /myapp/node_modules
+
+ADD prisma .
+RUN npx prisma generate
+
+ADD . .
+RUN npm run build
+
+# Finally, build the production image with minimal footprint.
+FROM base
+
+# Add shortcut for connecting to database CLI.
+RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
+
+WORKDIR /myapp
+
+COPY --from=production-deps /myapp/node_modules /myapp/node_modules
+COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma
+
+COPY --from=build /myapp/build /myapp/build
+COPY --from=build /myapp/public /myapp/public
+COPY --from=build /myapp/prisma /myapp/prisma
+COPY --from=build /myapp/package.json /myapp/package.json
+COPY --from=build /myapp/start.sh /myapp/start.sh
+
+ENTRYPOINT [ "./start.sh" ]
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..f130a4fd
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Daniel Kanem
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..eece5804
--- /dev/null
+++ b/README.md
@@ -0,0 +1,189 @@
+![GitHub-Mark-Light](https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Intro-Light.png#gh-light-mode-only)
+![GitHub-Mark-Dark ](https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Intro-Dark.png#gh-dark-mode-only)
+
+
+
+ Live Demo
+ ยท
+ Deployment Documentation
+ ยท
+ Twitter
+
+
+ A Stripe focused Remix Stack that integrates User Subscriptions, Authentication and Testing. Driven by Prisma ORM. Deploys to Fly.io
+
+
+
+## Features
+
+Template features are divided into two categories: **Base Features** and **Stack Features**.
+
+### Base Features
+
+- Database ORM with [Prisma.](https://www.prisma.io)
+- Production Ready with [SQLite](https://sqlite.org/index.html) and [PostgreSQL.](https://www.postgresql.org)
+- [Fly app Deployment](https://fly.io) with [Docker.](https://www.docker.com/products/docker-desktop)
+- [GitHub Actions](https://github.com/features/actions) for Deploy on merge to Production and Staging environments.
+- HealthCheck Endpoint for [Fly Backups.](https://fly.io/docs/reference/configuration/#services-http_checks)
+- Styling with [TailwindCSS](https://tailwindcss.com) + [Tailwind Prettier-Plugin.](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)
+- End-to-End testing with [Playwright.](https://playwright.dev)
+- Linting with [ESLint.](https://eslint.org)
+- Code formatting with [Prettier.](https://prettier.io)
+- Static Types with [TypeScript.](https://www.typescriptlang.org)
+- Support for Javascript developers based on `remix.init`.
+
+### Stack Features
+
+- [Stripe Subscriptions](https://stripe.com/docs/billing/subscriptions/overview) with support for [Billing Cycles](https://stripe.com/docs/billing/subscriptions/billing-cycle), [Multi Currency](https://stripe.com/docs/currencies) and [Customer Portal.](https://stripe.com/docs/billing/subscriptions/integrating-customer-portal)
+- Authentication Ready with [Remix-Auth](https://www.npmjs.com/package/remix-auth), [Remix Auth OTP](https://github.com/dev-xo/remix-auth-otp) and [Socials Strategies.](https://github.com/TheRealFlyingCoder/remix-auth-socials)
+- Flat Routes with [Remix Flat Routes.](https://github.com/kiliman/remix-flat-routes)
+
+Learn more about [Remix Stacks.](https://remix.run/stacks)
+
+> Stripe Stack v3 has been released after the integration of Supa-Stripe-Stack from [rphlmr](https://github.com/rphlmr). Special thanks to him for his great work and a big recommendation to check his [implementation](https://github.com/rphlmr/supa-stripe-stack).
+
+## Live Demo
+
+We've created a simple demo that displays all template provided features. Feel free to test it [here](https://stripe-stack-dev.fly.dev).
+
+[![Remix Auth OTP Stack](https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Thumbnail-Min.png)](https://stripe-stack-dev.fly.dev)
+
+Here's a simple workflow you can follow to test the template:
+
+1. Visit the [Live Demo](https://stripe-stack-dev.fly.dev).
+2. Log in with your preferred authentication method.
+3. Select a Subscription Plan and fill the Stripe Checkout inputs with its test values. _Check Notes._
+
+> **Note**
+> Stripe test mode uses the following number: `4242` as valid values for Card Information.
+> Type it as much times as you can on each available input to successfully complete the checkout step.
+
+---
+
+## Getting Started
+
+Before starting our development or even deploying our template, we'll require to setup a few things.
+
+## Template
+
+Stripe Stack has support for multiple database choices based on Prisma. The installer will prompt a selector allowing you to choose the database your project will run on.
+
+To get started, run the following commands in your console:
+
+```sh
+# Initialize template into your workspace:
+npx create-remix@latest --template dev-xo/stripe-stack
+
+# Select the database your project will run on:
+> SQLite or PostgreSQL
+
+# Done! ๐ฟ Please, keep reading the documentation to Get Started.
+```
+
+> **Note**
+> Cloning the repository instead of initializing it with the above commands, will result in a inappropriate experience. Stripe Stack uses `remix.init` to configure itself and prepare your environment.
+
+## Environment
+
+We'll require a few environment variables to get our app up and running.
+
+### Authentication Envs
+
+Stripe Stack has support for [Email Code](https://github.com/dev-xo/remix-auth-otp) _(Includes Magic Link)_ and [Socials](https://github.com/TheRealFlyingCoder/remix-auth-socials) Authentication Strategies. Feel free to visit the links to learn more about each one and how to configure them.
+
+### Stripe Envs
+
+In order to use Stripe Subscriptions and seed our database, we'll require to get the secret keys from our Stripe Dashboard.
+
+1. Create a [Stripe Account](https://dashboard.stripe.com/login) or use an existing one.
+2. Visit [API Keys](https://dashboard.stripe.com/test/apikeys) section and copy the `Publishable` and `Secret` keys.
+3. Paste each one of them into your `.env` file as `STRIPE_PUBLIC_KEY` and `STRIPE_SECRET_KEY` respectively.
+
+### Stripe Webhook Envs
+
+In order to start receiving Stripe Events to our Webhook Endpoint, we'll require to install the [Stripe CLI.](https://stripe.com/docs/stripe-cli) Once installed run the following command in your console:
+
+```sh
+stripe listen --forward-to localhost:3000/api/webhook
+```
+
+This should give you a Webhook Secret Key. Copy and paste it into your `.env` file as `DEV_STRIPE_WEBHOOK_ENDPOINT`.
+
+> **Note**
+> This command should be running in your console while developing.
+
+## Database
+
+Before starting our development, we'll require to setup our Prisma Migrations. First, ensure that your Prisma Schema is configured accordingly to your needs. Check `/prisma/schema.prisma` to learn more about it.
+
+Once you're done, run the following command in your console:
+
+```sh
+npx prisma migrate dev --name init --skip-seed
+```
+
+### Seeding Database
+
+Now that we have our database initialized, we'll require to seed it with our Stripe Plans. Check `/services/stripe/plans` to learn more about it.
+
+Once you're done, run the following command in your console:
+
+```sh
+npx prisma db seed
+```
+
+> **Warning**
+> You'll require to have your Stripe Envs already configured and no Stripe Products created with the same `id` as the ones in `/services/stripe/plans`.
+
+## Development Server
+
+Now that we have everything configured, we can start our development server. Run the following command in your console:
+
+```sh
+npm run dev
+```
+
+You should be able to access your app at ๐ [http://localhost:3000](http://localhost:3000).
+
+## Deployment
+
+Stripe Stack has support for SQLite and PostgreSQL databases. In order to keep a better track and an easier maintenance of each deployment documentation, we moved each one to its own file.
+
+Visit the [Deployment Docs](https://github.com/dev-xo/dev-xo/tree/main/stripe-stack/docs) in order to get your app to production.
+
+## GitHub Actions
+
+GitHub Actions are used for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests, build, etc. Anything in the `dev` branch will be deployed to staging.
+
+## Testing
+
+### Playwright
+
+We use Playwright for our End-to-End tests. You'll find those in `tests/e2e` directory.
+To run your tests in development use `npm run test:e2e:dev`.
+
+### Type Checking
+
+It's recommended to get TypeScript set up for your editor _(if your template uses it)_ to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project use `npm run typecheck`.
+
+### Linting
+
+This project uses ESLint for linting. That is configured in `.eslintrc.js`.
+To run linting across the whole project use `npm run lint`.
+
+### Formatting
+
+We use [Prettier](https://prettier.io/) for auto-formatting. It's recommended to install an editor plugin to get auto-formatting on save.
+To run formatting across the whole project use `npm run format`.
+
+This template has pre-configured prettier settings inside `.prettierrc`.
+Feel free to update each value with your preferred work style and tun the above command to format your project.
+
+## Support
+
+If you find this template useful, support it with a [Star โญ](https://github.com/dev-xo/stripe-stack)
+It helps the repository grow and gives me motivation to keep working on it. Thank you!
+
+## License
+
+Licensed under the [MIT License](https://github.com/dev-xo/stripe-stack/blob/main/LICENSE).
diff --git a/app/components/footer.tsx b/app/components/footer.tsx
new file mode 100644
index 00000000..6fdc9f5e
--- /dev/null
+++ b/app/components/footer.tsx
@@ -0,0 +1,55 @@
+import { Link, useLocation } from '@remix-run/react'
+
+export function Footer() {
+ const location = useLocation()
+
+ return location.pathname !== '/support' ? (
+
+ ) : (
+
+
+
+
+
+
+ Thank you!
+
+
+ )
+}
diff --git a/app/components/navigation.tsx b/app/components/navigation.tsx
new file mode 100644
index 00000000..f1ca67e2
--- /dev/null
+++ b/app/components/navigation.tsx
@@ -0,0 +1,114 @@
+import type { User } from '@prisma/client'
+import { Link, Form, useLocation } from '@remix-run/react'
+
+type NavigationProps = {
+ user: User | null
+}
+
+export function Navigation({ user }: NavigationProps) {
+ const location = useLocation()
+
+ return (
+
+ )
+}
diff --git a/app/components/stripe/checkout-button.tsx b/app/components/stripe/checkout-button.tsx
new file mode 100644
index 00000000..aa06aba2
--- /dev/null
+++ b/app/components/stripe/checkout-button.tsx
@@ -0,0 +1,57 @@
+import type { Plan } from '@prisma/client'
+import type { Interval } from '~/services/stripe/plans'
+
+import { PlanId } from '~/services/stripe/plans'
+import { useFetcher } from '@remix-run/react'
+
+type CheckoutButtonProps = {
+ currentPlanId: Plan['id'] | null
+ planId: Plan['id']
+ planName: Plan['name']
+ planInterval: Interval | string
+}
+
+export function CheckoutButton({
+ currentPlanId,
+ planId,
+ planName,
+ planInterval,
+}: CheckoutButtonProps) {
+ const fetcher = useFetcher()
+ const isLoading = fetcher.state !== 'idle'
+
+ const buttonClassName = () => {
+ switch (planId) {
+ case PlanId.FREE:
+ return 'bg-yellow-500 hover:bg-yellow-400'
+ case PlanId.STARTER:
+ return 'bg-green-500 hover:bg-green-400'
+ case PlanId.PRO:
+ return 'bg-violet-500 hover:bg-violet-400'
+ }
+ }
+
+ if (planId === currentPlanId) {
+ return (
+
+ Current
+
+ )
+ }
+
+ return (
+
+
+ {isLoading ? 'Redirecting ...' : `Get ${planName}`}
+
+
+ )
+}
diff --git a/app/components/stripe/customer-portal-button.tsx b/app/components/stripe/customer-portal-button.tsx
new file mode 100644
index 00000000..55dcea3d
--- /dev/null
+++ b/app/components/stripe/customer-portal-button.tsx
@@ -0,0 +1,16 @@
+import { useFetcher } from '@remix-run/react'
+
+export function CustomerPortalButton() {
+ const fetcher = useFetcher()
+ const isLoading = fetcher.state !== 'idle'
+
+ return (
+
+
+ {isLoading ? 'Redirecting ...' : 'Customer Portal'}
+
+
+ )
+}
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
new file mode 100644
index 00000000..8338545d
--- /dev/null
+++ b/app/entry.client.tsx
@@ -0,0 +1,22 @@
+import { RemixBrowser } from "@remix-run/react";
+import { startTransition, StrictMode } from "react";
+import { hydrateRoot } from "react-dom/client";
+
+function hydrate() {
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+}
+
+if (typeof requestIdleCallback === "function") {
+ requestIdleCallback(hydrate);
+} else {
+ // Safari doesn't support requestIdleCallback
+ // https://caniuse.com/requestidlecallback
+ setTimeout(hydrate, 1);
+}
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
new file mode 100644
index 00000000..3885e77f
--- /dev/null
+++ b/app/entry.server.tsx
@@ -0,0 +1,109 @@
+import type { EntryContext } from '@remix-run/node'
+
+import { PassThrough } from 'stream'
+import { Response } from '@remix-run/node'
+import { RemixServer } from '@remix-run/react'
+import { renderToPipeableStream } from 'react-dom/server'
+import { getSharedEnvs } from './utils/envs'
+
+import isbot from 'isbot'
+
+/**
+ * Global Shared Envs.
+ */
+global.ENV = getSharedEnvs()
+
+const ABORT_DELAY = 5000
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+) {
+ return isbot(request.headers.get('user-agent'))
+ ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
+ : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext)
+}
+
+function handleBotRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onAllReady() {
+ const body = new PassThrough()
+
+ responseHeaders.set('Content-Type', 'text/html')
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ }),
+ )
+
+ pipe(body)
+ },
+ onShellError(error: unknown) {
+ reject(error)
+ },
+ onError(error: unknown) {
+ didError = true
+
+ console.error(error)
+ },
+ },
+ )
+
+ setTimeout(abort, ABORT_DELAY)
+ })
+}
+
+function handleBrowserRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ remixContext: EntryContext,
+) {
+ return new Promise((resolve, reject) => {
+ let didError = false
+
+ const { pipe, abort } = renderToPipeableStream(
+ ,
+ {
+ onShellReady() {
+ const body = new PassThrough()
+
+ responseHeaders.set('Content-Type', 'text/html')
+
+ resolve(
+ new Response(body, {
+ headers: responseHeaders,
+ status: didError ? 500 : responseStatusCode,
+ }),
+ )
+
+ pipe(body)
+ },
+ onShellError(err: unknown) {
+ reject(err)
+ },
+ onError(error: unknown) {
+ didError = true
+
+ console.error(error)
+ },
+ },
+ )
+
+ setTimeout(abort, ABORT_DELAY)
+ })
+}
diff --git a/app/models/plan/create-plan.ts b/app/models/plan/create-plan.ts
new file mode 100644
index 00000000..4a911b91
--- /dev/null
+++ b/app/models/plan/create-plan.ts
@@ -0,0 +1,8 @@
+import type { Plan } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function createPlan(plan: Omit) {
+ return db.plan.create({
+ data: { ...plan },
+ })
+}
diff --git a/app/models/plan/delete-plan.ts b/app/models/plan/delete-plan.ts
new file mode 100644
index 00000000..f01f575d
--- /dev/null
+++ b/app/models/plan/delete-plan.ts
@@ -0,0 +1,8 @@
+import type { Plan } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function deletePlanById(id: Plan['id']) {
+ return db.plan.delete({
+ where: { id },
+ })
+}
diff --git a/app/models/plan/get-plan.ts b/app/models/plan/get-plan.ts
new file mode 100644
index 00000000..c539bef2
--- /dev/null
+++ b/app/models/plan/get-plan.ts
@@ -0,0 +1,21 @@
+import type { Prisma, Plan } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function getPlanById(id: Plan['id'], include?: Prisma.PlanInclude) {
+ return db.plan.findUnique({
+ where: { id },
+ include: {
+ ...include,
+ prices: include?.prices || false,
+ },
+ })
+}
+
+export async function getAllPlans(include?: Prisma.PlanInclude) {
+ return db.plan.findMany({
+ include: {
+ ...include,
+ prices: include?.prices || false,
+ },
+ })
+}
diff --git a/app/models/plan/update-plan.ts b/app/models/plan/update-plan.ts
new file mode 100644
index 00000000..49834457
--- /dev/null
+++ b/app/models/plan/update-plan.ts
@@ -0,0 +1,9 @@
+import type { Plan } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function updatePlanById(id: Plan['id'], plan: Partial) {
+ return db.plan.update({
+ where: { id },
+ data: { ...plan },
+ })
+}
diff --git a/app/models/subscription/create-subscription.ts b/app/models/subscription/create-subscription.ts
new file mode 100644
index 00000000..345912b2
--- /dev/null
+++ b/app/models/subscription/create-subscription.ts
@@ -0,0 +1,10 @@
+import type { Subscription } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function createSubscription(
+ subscription: Omit,
+) {
+ return db.subscription.create({
+ data: { ...subscription },
+ })
+}
diff --git a/app/models/subscription/delete-subscription.ts b/app/models/subscription/delete-subscription.ts
new file mode 100644
index 00000000..0309cfb3
--- /dev/null
+++ b/app/models/subscription/delete-subscription.ts
@@ -0,0 +1,8 @@
+import type { Subscription } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function deleteSubscriptionById(id: Subscription['id']) {
+ return db.subscription.delete({
+ where: { id },
+ })
+}
diff --git a/app/models/subscription/get-subscription.ts b/app/models/subscription/get-subscription.ts
new file mode 100644
index 00000000..2749dda7
--- /dev/null
+++ b/app/models/subscription/get-subscription.ts
@@ -0,0 +1,14 @@
+import type { User, Subscription } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function getSubscriptionById(id: Subscription['id']) {
+ return db.subscription.findUnique({
+ where: { id },
+ })
+}
+
+export async function getSubscriptionByUserId(userId: User['id']) {
+ return db.subscription.findUnique({
+ where: { userId },
+ })
+}
diff --git a/app/models/subscription/update-subscription.ts b/app/models/subscription/update-subscription.ts
new file mode 100644
index 00000000..8b44e3f9
--- /dev/null
+++ b/app/models/subscription/update-subscription.ts
@@ -0,0 +1,22 @@
+import type { Subscription } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function updateSubscriptionById(
+ id: Subscription['id'],
+ subscription: Partial,
+) {
+ return db.subscription.update({
+ where: { id },
+ data: { ...subscription },
+ })
+}
+
+export async function updateSubscriptionByUserId(
+ userId: Subscription['userId'],
+ subscription: Partial,
+) {
+ return db.subscription.update({
+ where: { userId },
+ data: { ...subscription },
+ })
+}
diff --git a/app/models/user/create-user.ts b/app/models/user/create-user.ts
new file mode 100644
index 00000000..7c143ee1
--- /dev/null
+++ b/app/models/user/create-user.ts
@@ -0,0 +1,8 @@
+import type { User } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function createUser(user: Pick) {
+ return db.user.create({
+ data: { ...user },
+ })
+}
diff --git a/app/models/user/delete-user.ts b/app/models/user/delete-user.ts
new file mode 100644
index 00000000..d46518d2
--- /dev/null
+++ b/app/models/user/delete-user.ts
@@ -0,0 +1,8 @@
+import type { User } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function deleteUserById(id: User['id']) {
+ return db.user.delete({
+ where: { id },
+ })
+}
diff --git a/app/models/user/get-user.ts b/app/models/user/get-user.ts
new file mode 100644
index 00000000..ce515e94
--- /dev/null
+++ b/app/models/user/get-user.ts
@@ -0,0 +1,22 @@
+import type { User } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function getUserById(id: User['id']) {
+ return db.user.findUnique({
+ where: { id },
+ })
+}
+
+export async function getUserByEmail(email: User['email']) {
+ return db.user.findUnique({
+ where: { email },
+ })
+}
+
+export async function getUserByCustomerId(customerId: User['customerId']) {
+ if (!customerId) throw new Error('Missing required parameters to retrieve User.')
+
+ return db.user.findUnique({
+ where: { customerId },
+ })
+}
diff --git a/app/models/user/update-user.ts b/app/models/user/update-user.ts
new file mode 100644
index 00000000..3f245f4b
--- /dev/null
+++ b/app/models/user/update-user.ts
@@ -0,0 +1,9 @@
+import type { User } from '@prisma/client'
+import { db } from '~/utils/db'
+
+export async function updateUserById(id: User['id'], user: Partial) {
+ return db.user.update({
+ where: { id },
+ data: { ...user },
+ })
+}
diff --git a/app/root.css b/app/root.css
new file mode 100644
index 00000000..e6dffa09
--- /dev/null
+++ b/app/root.css
@@ -0,0 +1,122 @@
+/*
+* This file will initialize TailwindCSS.
+* Docs: https://tailwindcss.com/docs/guides/remix
+*/
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/*
+* Custom Body.
+*/
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ color: #e5e7eb;
+ background-color: #0d1117;
+
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+textarea:focus,
+input:focus {
+ outline: none;
+}
+
+/*
+* Custom Scrollbar.
+*/
+:root {
+ scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ background-color: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 8px;
+}
+
+/*
+* Custom Selection Background.
+*/
+::-moz-selection {
+ color: #fff;
+ background: #7b61ff;
+}
+
+::selection {
+ color: #fff;
+ background: #7b61ff;
+}
+
+/*
+* Custom Background.
+*/
+.blobs {
+ z-index: -1;
+ max-width: 640px;
+ background-image: radial-gradient(at 27% 37%, #000 0, transparent 0),
+ radial-gradient(at 97% 21%, #8b5cf6 0, transparent 50%),
+ radial-gradient(at 52% 99%, #6f16ff 0, transparent 50%),
+ radial-gradient(at 10% 29%, #8b5cf6 0, transparent 50%),
+ radial-gradient(at 97% 96%, #000 0, transparent 50%),
+ radial-gradient(at 33% 50%, #000 0, transparent 50%),
+ radial-gradient(at 79% 53%, #000 0, transparent 50%);
+ position: absolute;
+ content: '';
+ width: 50%;
+ height: 50%;
+ filter: blur(100px) saturate(150%);
+ top: 25%;
+ opacity: 0.4;
+}
+
+/*
+* Animations.
+*/
+@keyframes pulse {
+ from {
+ transform: scale3d(1, 1, 1);
+ }
+
+ 50% {
+ transform: scale3d(1.2, 1.2, 1.2);
+ }
+
+ to {
+ transform: scale3d(1, 1, 1);
+ }
+}
+
+@keyframes float {
+ 0% {
+ transform: translatey(0px);
+ }
+ 50% {
+ transform: translatey(-20px);
+ }
+ 100% {
+ transform: translatey(0px);
+ }
+}
+
+.pulse {
+ animation-name: pulse;
+ animation-duration: 4s;
+ animation-timing-function: ease-in-out;
+ animation-iteration-count: infinite;
+}
+
+.float {
+ animation-name: float;
+ animation-duration: 6s;
+ animation-timing-function: ease-in-out;
+ animation-iteration-count: infinite;
+}
diff --git a/app/root.tsx b/app/root.tsx
new file mode 100644
index 00000000..17fa4fe4
--- /dev/null
+++ b/app/root.tsx
@@ -0,0 +1,77 @@
+import type { LinksFunction, MetaFunction, DataFunctionArgs } from '@remix-run/node'
+import {
+ Links,
+ LiveReload,
+ Meta,
+ Outlet,
+ Scripts,
+ ScrollRestoration,
+ useLoaderData,
+} from '@remix-run/react'
+
+import { getSharedEnvs } from './utils/envs'
+import TailwindCSS from './root.css'
+
+export const links: LinksFunction = () => {
+ return [{ rel: 'stylesheet', href: TailwindCSS }]
+}
+
+export const meta: MetaFunction = () => {
+ return {
+ viewport: 'width=device-width, initial-scale=1',
+ charset: 'utf-8',
+ title: 'Stripe Stack - Remix',
+ description: `A Stripe focused Remix Stack that integrates User Subscriptions,
+ Authentication and Testing. Driven by Prisma ORM. Deploys to Fly.io`,
+ keywords:
+ 'remix, stripe, remix-stack, typescript, sqlite, postgresql, prisma, tailwindcss, fly.io',
+ 'og:title': 'Stripe Stack - Remix',
+ 'og:type': 'website',
+ 'og:url': 'https://stripe-stack.fly.dev',
+ 'og:image':
+ 'https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Stripe-Thumbnail.png',
+ 'og:card': 'summary_large_image',
+ 'og:creator': '@DanielKanem',
+ 'og:site': 'https://stripe-stack.fly.dev',
+ 'og:description': `A Stripe focused Remix Stack that integrates User Subscriptions,
+ Authentication and Testing. Driven by Prisma ORM. Deploys to Fly.io`,
+ 'twitter:image':
+ 'https://raw.githubusercontent.com/dev-xo/dev-xo/main/stripe-stack/assets/images/Stripe-Thumbnail.png',
+ 'twitter:card': 'summary_large_image',
+ 'twitter:creator': '@DanielKanem',
+ 'twitter:title': 'Stripe Stack - Remix',
+ 'twitter:description': `A Stripe focused Remix Stack that integrates User Subscriptions,
+ Authentication and Testing. Driven by Prisma ORM. Deploys to Fly.io`,
+ }
+}
+
+export function loader({ request }: DataFunctionArgs) {
+ return { ENV: getSharedEnvs() }
+}
+
+export default function App() {
+ const { ENV } = useLoaderData()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {/* Global Shared Envs. */}
+
+
+ {process.env.NODE_ENV === 'development' && }
+
+
+ )
+}
diff --git a/app/routes/_layout+/_index.tsx b/app/routes/_layout+/_index.tsx
new file mode 100644
index 00000000..b4f10b6f
--- /dev/null
+++ b/app/routes/_layout+/_index.tsx
@@ -0,0 +1,140 @@
+import { Link } from '@remix-run/react'
+
+export default function Index() {
+ return (
+
+ {/* Main. */}
+
+ {/* Packages. */}
+
+
+
+
+
+
+ {/* Logo. */}
+
+
+
+ {/* Headings. */}
+
+
+ Remix Stacks
+
+
+ Open Source Template
+
+
+
+
+
+
+
+ Stripe
+ {' '}
+ Subscriptions
+
+
+ made simpler
+
+
+
+
+
+ Easily manage{' '}
+
+ Stripe Subscriptions
+
+ ,{' '}
+
+ Customer Portal
+ {' '}
+ and{' '}
+
+ User Account
+ {' '}
+ in the same template.
+
+
+
+ {/* Buttons. */}
+
+
+
+ )
+}
diff --git a/app/routes/_layout+/_layout.tsx b/app/routes/_layout+/_layout.tsx
new file mode 100644
index 00000000..093b840a
--- /dev/null
+++ b/app/routes/_layout+/_layout.tsx
@@ -0,0 +1,45 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+import type { User } from '@prisma/client'
+
+import { redirect, json } from '@remix-run/node'
+import { useLoaderData, Outlet } from '@remix-run/react'
+import { authenticator } from '~/services/auth/config.server'
+
+import { Navigation } from '~/components/navigation'
+import { Footer } from '~/components/footer'
+
+type LoaderData = {
+ user: User | null
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request)
+
+ // Force redirect to /account on authenticated user.
+ const url = new URL(request.url)
+ if (session && url.pathname === '/') return redirect('/account')
+
+ return json({ user: session })
+}
+
+export default function Layout() {
+ // Check bellow info about why we are force casting
+ // https://github.com/remix-run/remix/issues/3931
+ const { user } = useLoaderData() as LoaderData
+
+ return (
+
+ {/* Background. */}
+
+
+ {/* Navigation. */}
+
+
+ {/* Outlet. */}
+
+
+ {/* Footer. */}
+
+
+ )
+}
diff --git a/app/routes/_layout+/account.tsx b/app/routes/_layout+/account.tsx
new file mode 100644
index 00000000..c1db08d3
--- /dev/null
+++ b/app/routes/_layout+/account.tsx
@@ -0,0 +1,198 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+import type { User, Subscription } from '@prisma/client'
+
+import { json, redirect } from '@remix-run/node'
+import { Form, Link, useLoaderData } from '@remix-run/react'
+import { authenticator } from '~/services/auth/config.server'
+
+import { PlanId, PRICING_PLANS } from '~/services/stripe/plans'
+import { getSubscriptionByUserId } from '~/models/subscription/get-subscription'
+import { getUserById } from '~/models/user/get-user'
+
+import { formatUnixDate } from '~/utils/date'
+import { CustomerPortalButton } from '~/components/stripe/customer-portal-button'
+
+type LoaderData = {
+ user: User
+ subscription: Omit
+}
+
+export async function loader({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/login',
+ })
+
+ const user = await getUserById(session.id)
+ if (!user) return redirect('/login')
+
+ const subscription = await getSubscriptionByUserId(user.id)
+
+ // Redirect with the intent to setup user name.
+ if (!user.name) return redirect('/register/name')
+
+ // Redirect with the intent to setup user customer.
+ if (!user.customerId) return redirect('/resources/stripe/create-customer')
+
+ // Redirect with the intent to setup a free user subscription.
+ if (!subscription) return redirect('/resources/stripe/create-subscription')
+
+ return json({ user, subscription })
+}
+
+export default function Account() {
+ // Check bellow info about why we are force casting
+ // https://github.com/remix-run/remix/issues/3931
+ const { user, subscription } = useLoaderData() as LoaderData
+
+ // This is just for debugging purposes. (Feel free to remove it.)
+ console.log(user)
+
+ return (
+
+
+
Dashboard
+
+
+ Simple Dashboard example that includes User and Subscription.
+
+
+
+
+
+ {/* User. */}
+
+ {/* Avatar. */}
+
+
+
+ {/* Info. */}
+
+
+ {user.name ? user.name : user.email}
+
+
+
+
+
+
+
+ My account
+
+
+
+
+ {/* Delete User Form Action. */}
+
+
+
+ {/* Subscription. */}
+
+ {/* Images. */}
+
+ {subscription.planId === PlanId.FREE && (
+
+ )}
+ {subscription.planId === PlanId.STARTER && (
+
+ )}
+ {subscription.planId === PlanId.PRO && (
+
+ )}
+
+
+
+ {/* Info. */}
+
+
+ {String(subscription.planId).charAt(0).toUpperCase() +
+ subscription.planId.slice(1)}{' '}
+ Plan
+
+
+
+ {subscription.planId === PlanId.FREE &&
+ PRICING_PLANS[PlanId.FREE].description}
+
+ {subscription.planId === PlanId.STARTER &&
+ PRICING_PLANS[PlanId.STARTER].description}
+
+ {subscription.planId === PlanId.PRO &&
+ PRICING_PLANS[PlanId.PRO].description}
+
+
+
+
+ {/* Plans Link. */}
+ {subscription.planId === PlanId.FREE && (
+ <>
+
+
Subscribe
+
+
+ >
+ )}
+
+ {/* Customer Portal. */}
+ {user.customerId &&
}
+
+ {/* Expire / Renew Date. */}
+ {subscription.planId !== PlanId.FREE ? (
+
+
+
+ Your subscription{' '}
+ {subscription.cancelAtPeriodEnd === true ? (
+ expires
+ ) : (
+ renews
+ )}{' '}
+ on:{' '}
+
+ {subscription && formatUnixDate(subscription.currentPeriodEnd)}
+
+
+
+ ) : (
+
+
+
+ Your Free Plan is unlimited.
+
+
+ )}
+
+
+
+ )
+}
diff --git a/app/routes/_layout+/checkout.tsx b/app/routes/_layout+/checkout.tsx
new file mode 100644
index 00000000..67c1e904
--- /dev/null
+++ b/app/routes/_layout+/checkout.tsx
@@ -0,0 +1,118 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { useState } from 'react'
+import { Link, useLoaderData, useSubmit } from '@remix-run/react'
+import { redirect, json } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+
+import { PlanId } from '~/services/stripe/plans'
+import { getSubscriptionByUserId } from '~/models/subscription/get-subscription'
+import { useInterval } from '~/utils/hooks'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/login',
+ })
+ const subscription = await getSubscriptionByUserId(session.id)
+
+ // User is already subscribed.
+ if (subscription?.planId !== PlanId.FREE) return redirect('/account')
+
+ return json({
+ pending: subscription?.planId === PlanId.FREE,
+ })
+}
+
+export default function Checkout() {
+ const { pending } = useLoaderData()
+ const [retries, setRetries] = useState(0)
+ const submit = useSubmit()
+
+ // Re-fetch subscription every 'x' seconds.
+ useInterval(
+ () => {
+ submit(null)
+ setRetries(retries + 1)
+ },
+ pending && retries !== 3 ? 2_000 : null,
+ )
+
+ return (
+
+ {/* Pending Message. */}
+ {pending && retries < 3 && (
+ <>
+
+
+
+
+
+
+ Completing your checkout ...
+
+
+
+ This will take a few seconds.
+
+ >
+ )}
+
+ {/* Success Message. */}
+ {!pending && (
+ <>
+
+
+
+
Checkout completed!
+
+
+ Enjoy your new subscription plan!
+
+
+
+
+ Continue to Account
+
+ >
+ )}
+
+ {/* Error Message. */}
+ {pending && retries === 3 && (
+ <>
+
+
+
+
Whops!
+
+
+ Something went wrong. Please contact us directly and we will solve it for you.
+
+
+
+
+ Continue to Account
+
+ >
+ )}
+
+ )
+}
diff --git a/app/routes/_layout+/login.email.tsx b/app/routes/_layout+/login.email.tsx
new file mode 100644
index 00000000..6fa3cb5f
--- /dev/null
+++ b/app/routes/_layout+/login.email.tsx
@@ -0,0 +1,165 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { json } from '@remix-run/node'
+import { Form, useLoaderData } from '@remix-run/react'
+
+import { authenticator } from '~/services/auth/config.server'
+import { getSession, commitSession } from '~/services/auth/session.server'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const userSession = await authenticator.isAuthenticated(request, {
+ successRedirect: '/account',
+ })
+
+ const session = await getSession(request.headers.get('Cookie'))
+ const hasSentEmail = session.has('auth:otp')
+
+ const email = session.get('auth:email') as string // Temporary force casting to string.
+ const error = session.get(authenticator.sessionErrorKey)
+
+ // Commit session, clearing any possible error.
+ return json(
+ { user: userSession, hasSentEmail, email, error },
+ {
+ headers: {
+ 'Set-Cookie': await commitSession(session),
+ },
+ },
+ )
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ await authenticator.authenticate('OTP', request, {
+ successRedirect: '/login/email',
+ failureRedirect: '/login/email',
+ })
+}
+
+export default function Login() {
+ let { user, hasSentEmail, email, error } = useLoaderData()
+
+ return (
+
+ {/* Headers. */}
+
+
+
+
+
+ Continue with Email
+
+
+
+ {/* Email confirmation. */}
+
+ {hasSentEmail ? (
+ Email has been successfully sent.
+ ) : (
+
+ Type bellow your email, and we'll send you a One Time Password code.
+
+ )}
+
+
+
+
+ {/* Error messages. */}
+ {error && (
+ <>
+
{error.message}
+
+ >
+ )}
+
+ {/* Email Form. */}
+ {!user && !hasSentEmail && (
+ <>
+
+ >
+ )}
+
+ {/* Verify Code Form. */}
+ {hasSentEmail && (
+
+
+
+
+ {/* Request new Code Form. */}
+ {/* Input should be hidden, email its already stored in Session. */}
+
+
+ )}
+
+ )
+}
diff --git a/app/routes/_layout+/login.index.tsx b/app/routes/_layout+/login.index.tsx
new file mode 100644
index 00000000..befed316
--- /dev/null
+++ b/app/routes/_layout+/login.index.tsx
@@ -0,0 +1,40 @@
+import { Form, Link } from '@remix-run/react'
+
+export default function LoginIndex() {
+ return (
+
+ {/* Google. */}
+
+
+
+ {/* Email. */}
+
+
+
+
+
+
Continue with Email
+
+
+ )
+}
diff --git a/app/routes/_layout+/login.tsx b/app/routes/_layout+/login.tsx
new file mode 100644
index 00000000..3c37093d
--- /dev/null
+++ b/app/routes/_layout+/login.tsx
@@ -0,0 +1,54 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { json } from '@remix-run/node'
+import { Outlet, useLocation } from '@remix-run/react'
+import { authenticator } from '~/services/auth/config.server'
+
+export async function loader({ request }: DataFunctionArgs) {
+ await authenticator.isAuthenticated(request, {
+ successRedirect: '/account',
+ })
+ return json({})
+}
+
+export default function Login() {
+ const location = useLocation()
+
+ return (
+
+ {/* Headers. */}
+ {location && location.pathname === '/login' && (
+ <>
+
+
+
+
+
+ Welcome back traveler.
+
+
+
+
+ Please, continue with your preferred authentication method.
+
+
+
+ >
+ )}
+
+ {/* Outlet. */}
+
+
+
+ {/* Example Privacy Message. */}
+
+ By clicking โContinue" you acknowledge that this is a simple demo, and can be used
+ in the way you like.
+
+
+ )
+}
diff --git a/app/routes/_layout+/plans.tsx b/app/routes/_layout+/plans.tsx
new file mode 100644
index 00000000..7da21cc8
--- /dev/null
+++ b/app/routes/_layout+/plans.tsx
@@ -0,0 +1,180 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { useState } from 'react'
+import { json } from '@remix-run/node'
+import { Link, useLoaderData } from '@remix-run/react'
+
+import { authenticator } from '~/services/auth/config.server'
+import { getSubscriptionByUserId } from '~/models/subscription/get-subscription'
+import { getDefaultCurrency } from '~/utils/locales'
+
+import { PlanId, Interval, Currency, PRICING_PLANS } from '~/services/stripe/plans'
+import { CheckoutButton } from '~/components/stripe/checkout-button'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request)
+ const subscription = session?.id ? await getSubscriptionByUserId(session.id) : null
+
+ // Get client's currency.
+ const defaultCurrency = getDefaultCurrency(request)
+
+ return json({
+ user: session,
+ subscription,
+ defaultCurrency,
+ })
+}
+
+export default function Plans() {
+ const { user, subscription, defaultCurrency } = useLoaderData()
+ const [planInterval, setPlanInterval] = useState(
+ subscription?.interval || Interval.MONTH,
+ )
+
+ return (
+
+ {/* Header. */}
+
+
Select your plan
+
+
+ You can test the upgrade and won't be charged.
+
+
+
+
+ {/* Toggler. */}
+
+
+ {planInterval === Interval.MONTH ? 'Monthly' : 'Yearly'}
+
+
+
+
+
+
+ setPlanInterval((prev) =>
+ prev === Interval.MONTH ? Interval.YEAR : Interval.MONTH,
+ )
+ }
+ />
+
+
+
+
+
+
+ {/* Plans. */}
+
+ {Object.values(PRICING_PLANS).map((plan) => {
+ return (
+
+ {/* Thumbnail. */}
+ {plan.id === PlanId['FREE'] && (
+
+ )}
+ {plan.id === PlanId['STARTER'] && (
+
+
+
+
+ )}
+
+ {plan.id === PlanId['PRO'] && (
+
+ )}
+
+
+ {/* Name. */}
+
{plan.name}
+
+
+ {/* Price Amount. */}
+
+ {defaultCurrency === Currency.EUR ? 'โฌ' : '$'}
+ {planInterval === Interval.MONTH
+ ? plan.prices[Interval.MONTH][defaultCurrency] / 100
+ : plan.prices[Interval.YEAR][defaultCurrency] / 100}
+
+ {planInterval === Interval.MONTH ? '/mo' : '/yr'}
+
+
+
+
+ {/* Features. */}
+ {plan.features.map((feature) => {
+ return (
+
+
+
+
+
+
+ {feature}
+
+
+ )
+ })}
+
+
+ {/* Checkout Component. */}
+ {user && (
+
+ )}
+
+ )
+ })}
+
+
+ {!user && (
+
+
+ Get Started
+
+
+ )}
+
+ )
+}
diff --git a/app/routes/_layout+/register.name.tsx b/app/routes/_layout+/register.name.tsx
new file mode 100644
index 00000000..b865c1d2
--- /dev/null
+++ b/app/routes/_layout+/register.name.tsx
@@ -0,0 +1,104 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { json, redirect } from '@remix-run/node'
+import { useFetcher } from '@remix-run/react'
+
+import { authenticator } from '~/services/auth/config.server'
+import { getUserById } from '~/models/user/get-user'
+import { updateUserById } from '~/models/user/update-user'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/login',
+ })
+
+ const user = await getUserById(session.id)
+ if (!user) return redirect('/login')
+ if (user.name) return redirect('/account')
+
+ return json({})
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/login',
+ })
+
+ // Get form values.
+ const { name } = Object.fromEntries(await request.formData())
+ if (typeof name !== 'string') throw new Error('Name is required.')
+
+ // Update user.
+ await updateUserById(session.id, { name })
+
+ return redirect('/account')
+}
+
+export default function RegisterName() {
+ const fetcher = useFetcher()
+ const isLoading = fetcher.state !== 'idle'
+
+ return (
+
+ {/* Headers. */}
+
+
+
+
+
About you.
+
+
+
+ Choose a name for your account.
+ You can change it later.
+
+
+
+
+ {/* Name Form. */}
+
+
+
+ Name
+
+
+
+
+
+
+
+ {!isLoading ? (
+
+ Continue
+
+ ) : (
+
+
+
+
+
+
+ Setting up your account
+
+ )}
+
+
+ )
+}
diff --git a/app/routes/_layout+/register.tsx b/app/routes/_layout+/register.tsx
new file mode 100644
index 00000000..a4dab85c
--- /dev/null
+++ b/app/routes/_layout+/register.tsx
@@ -0,0 +1,20 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { json } from '@remix-run/node'
+import { Outlet } from '@remix-run/react'
+import { authenticator } from '~/services/auth/config.server'
+
+export async function loader({ request }: DataFunctionArgs) {
+ await authenticator.isAuthenticated(request, {
+ failureRedirect: '/login',
+ })
+ return json({})
+}
+
+export default function Register() {
+ return (
+
+
+
+ )
+}
diff --git a/app/routes/_layout+/support.tsx b/app/routes/_layout+/support.tsx
new file mode 100644
index 00000000..e3fd8cf0
--- /dev/null
+++ b/app/routes/_layout+/support.tsx
@@ -0,0 +1,65 @@
+import { useState } from 'react'
+
+export default function Support() {
+ const [displayMethods, setDisplayMethods] = useState(false)
+
+ return (
+
+ {/* Avatar. */}
+
+
+
+ {/* Info. */}
+
+
+ ๐ Hello, I'm Daniel
+
+
+
+ I'm a Full Stack Developer from Spain.
+
+
+
+
+ {/* Support Methods. */}
+
+ Would you like to support this template?
+ You can do it by using any of the following crypto addresses:
+
+
+
+ {displayMethods && (
+ <>
+
+
+ BTC: {' '}
+ bc1qew0jrlc8z29afrtftss2zsah6uw6harjyw8kg3
+
+
+
+ ETH: {' '}
+ D6nkNaFKfBFGS9UPmSkKXJ6EmJfUBND15a
+
+
+
+ DOGE: {' '}
+ 0x83997E043Cf3983B4D90DC15df80f6004a1B3a26
+
+
+
+ >
+ )}
+
+
setDisplayMethods(!displayMethods)}
+ className="flex h-10 w-48 flex-row items-center justify-center rounded-xl border border-gray-600 px-4 font-bold
+ text-gray-200 transition hover:scale-105 hover:border-gray-200 hover:text-gray-100 active:opacity-80">
+ {displayMethods ? 'Hide Methods' : 'Display Methods'}
+
+
+ )
+}
diff --git a/app/routes/api+/healthcheck.ts b/app/routes/api+/healthcheck.ts
new file mode 100644
index 00000000..9af490b8
--- /dev/null
+++ b/app/routes/api+/healthcheck.ts
@@ -0,0 +1,26 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+import { db } from '~/utils/db'
+
+/**
+ * Learn more about Fly.io Health Check:
+ * https://fly.io/docs/reference/configuration/#services-http_checks
+ */
+export async function loader({ request }: DataFunctionArgs) {
+ const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host')
+
+ try {
+ const url = new URL('/', `http://${host}`)
+ await Promise.all([
+ db.user.count(),
+
+ fetch(url.toString(), { method: 'HEAD' }).then((res) => {
+ if (!res.ok) return Promise.reject(res)
+ }),
+ ])
+
+ return new Response('OK')
+ } catch (err: unknown) {
+ console.log('Healthcheck Error:', { err })
+ return new Response('Healthcheck Error.', { status: 500 })
+ }
+}
diff --git a/app/routes/api+/webhook.ts b/app/routes/api+/webhook.ts
new file mode 100644
index 00000000..936b7099
--- /dev/null
+++ b/app/routes/api+/webhook.ts
@@ -0,0 +1,138 @@
+// Required for an enhanced experience with Stripe Event Types.
+// More info: https://bit.ly/3KlNXLs
+///
+
+import type { DataFunctionArgs } from '@remix-run/node'
+import type { Stripe } from 'stripe'
+
+import { json } from '@remix-run/node'
+import { stripe } from '~/services/stripe/config.server'
+import { PlanId } from '~/services/stripe/plans'
+import { retrieveStripeSubscription } from '~/services/stripe/api/retrieve-subscription'
+
+import { getUserByCustomerId } from '~/models/user/get-user'
+import { getSubscriptionById } from '~/models/subscription/get-subscription'
+import { updateSubscriptionByUserId } from '~/models/subscription/update-subscription'
+import { deleteSubscriptionById } from '~/models/subscription/delete-subscription'
+
+/**
+ * Gets Stripe event signature from request header.
+ */
+async function getStripeEvent(request: Request) {
+ try {
+ // Get header Stripe signature.
+ const signature = request.headers.get('stripe-signature')
+ if (!signature) throw new Error('Missing Stripe signature.')
+
+ const ENDPOINT_SECRET =
+ process.env.NODE_ENV === 'development'
+ ? process.env.DEV_STRIPE_WEBHOOK_ENDPOINT
+ : process.env.PROD_STRIPE_WEBHOOK_ENDPOINT
+
+ const payload = await request.text()
+ const event = stripe.webhooks.constructEvent(
+ payload,
+ signature,
+ ENDPOINT_SECRET,
+ ) as Stripe.DiscriminatedEvent
+
+ return event
+ } catch (err: unknown) {
+ console.log(err)
+ return json({}, { status: 400 })
+ }
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const event = await getStripeEvent(request)
+
+ try {
+ switch (event.type) {
+ // Occurs when a Checkout Session has been successfully completed.
+ case 'checkout.session.completed': {
+ const session = event.data.object
+ const customerId = String(session.customer)
+ const subscriptionId = String(session.subscription)
+
+ // Get user from database.
+ const user = await getUserByCustomerId(customerId)
+ if (!user) throw new Error('User not found.')
+
+ // Retrieve and update database subscription.
+ const subscription = await retrieveStripeSubscription(subscriptionId)
+ await updateSubscriptionByUserId(user.id, {
+ id: subscription.id,
+ planId: String(subscription.items.data[0].plan.product),
+ priceId: String(subscription.items.data[0].price.id),
+ interval: String(subscription.items.data[0].plan.interval),
+ status: subscription.status,
+ currentPeriodStart: subscription.current_period_start,
+ currentPeriodEnd: subscription.current_period_end,
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
+ })
+
+ return json({}, { status: 200 })
+ }
+
+ // Occurs whenever a subscription changes (e.g. plan switch).
+ case 'customer.subscription.updated': {
+ const subscription = event.data.object
+ const customerId = String(subscription.customer)
+
+ // Get user from database.
+ const user = await getUserByCustomerId(customerId)
+ if (!user) throw new Error('User not found.')
+
+ // Cancel free subscription if user has a paid one.
+ const subscriptionsList = await stripe.subscriptions.list({ limit: 3 })
+ const freeSubscription = subscriptionsList.data
+ .map((subscription) => {
+ return subscription.items.data.find(
+ (item) => item.price.product === PlanId.FREE,
+ )
+ })
+ .filter((item) => item !== undefined)
+
+ if (freeSubscription[0]) {
+ await stripe.subscriptions.del(freeSubscription[0].subscription)
+ }
+
+ // Update database subscription.
+ await updateSubscriptionByUserId(user.id, {
+ id: subscription.id,
+ planId: String(subscription.items.data[0].plan.product),
+ priceId: String(subscription.items.data[0].price.id),
+ interval: String(subscription.items.data[0].plan.interval),
+ status: subscription.status,
+ currentPeriodStart: subscription.current_period_start,
+ currentPeriodEnd: subscription.current_period_end,
+ cancelAtPeriodEnd: subscription.cancel_at_period_end,
+ })
+
+ return json({}, { status: 200 })
+ }
+
+ // Occurs whenever a customerโs subscription ends.
+ case 'customer.subscription.deleted': {
+ const subscription = event.data.object
+
+ // Get database subscription.
+ const dbSubscription = await getSubscriptionById(subscription.id)
+
+ if (dbSubscription) {
+ // Delete database subscription.
+ await deleteSubscriptionById(subscription.id)
+ }
+
+ return json({}, { status: 200 })
+ }
+ }
+ } catch (err: unknown) {
+ console.log(err)
+ return json({}, { status: 400 })
+ }
+
+ // We'll return a 200 status code for all other events.
+ // A `501 Not Implemented` or any other status code could be returned.
+ return json({}, { status: 200 })
+}
diff --git a/app/routes/auth+/$provider.callback.tsx b/app/routes/auth+/$provider.callback.tsx
new file mode 100644
index 00000000..0f608d47
--- /dev/null
+++ b/app/routes/auth+/$provider.callback.tsx
@@ -0,0 +1,19 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+
+export async function loader({ request, params }: DataFunctionArgs) {
+ if (typeof params.provider !== 'string') throw new Error('Invalid provider.')
+
+ return await authenticator.authenticate(params.provider, request, {
+ successRedirect: '/account',
+ failureRedirect: '/login',
+ })
+}
+
+export default function Screen() {
+ return (
+
+ Whops! You should have already been redirected.
+
+ )
+}
diff --git a/app/routes/auth+/$provider.tsx b/app/routes/auth+/$provider.tsx
new file mode 100644
index 00000000..c9cafa7c
--- /dev/null
+++ b/app/routes/auth+/$provider.tsx
@@ -0,0 +1,19 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+
+export async function action({ request, params }: DataFunctionArgs) {
+ if (typeof params.provider !== 'string') throw new Error('Invalid provider.')
+
+ return await authenticator.authenticate(params.provider, request, {
+ successRedirect: '/account',
+ failureRedirect: '/login',
+ })
+}
+
+export default function Screen() {
+ return (
+
+ Whops! You should have already been redirected.
+
+ )
+}
diff --git a/app/routes/auth+/logout.tsx b/app/routes/auth+/logout.tsx
new file mode 100644
index 00000000..8eb2537f
--- /dev/null
+++ b/app/routes/auth+/logout.tsx
@@ -0,0 +1,14 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+
+export async function action({ request }: DataFunctionArgs) {
+ return await authenticator.logout(request, { redirectTo: '/' })
+}
+
+export default function Screen() {
+ return (
+
+ Whops! You should have already been redirected.
+
+ )
+}
diff --git a/app/routes/auth+/magic.tsx b/app/routes/auth+/magic.tsx
new file mode 100644
index 00000000..6b62355d
--- /dev/null
+++ b/app/routes/auth+/magic.tsx
@@ -0,0 +1,17 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+
+export async function loader({ request }: DataFunctionArgs) {
+ await authenticator.authenticate('OTP', request, {
+ successRedirect: '/account',
+ failureRedirect: '/login',
+ })
+}
+
+export default function Screen() {
+ return (
+
+ Whops! You should have already been redirected.
+
+ )
+}
diff --git a/app/routes/resources+/stripe.create-checkout.ts b/app/routes/resources+/stripe.create-checkout.ts
new file mode 100644
index 00000000..bc2d8dc2
--- /dev/null
+++ b/app/routes/resources+/stripe.create-checkout.ts
@@ -0,0 +1,47 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { redirect } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+
+import { getUserById } from '~/models/user/get-user'
+import { getPlanById } from '~/models/plan/get-plan'
+import { getDefaultCurrency } from '~/utils/locales'
+import { createStripeCheckoutSession } from '~/services/stripe/api/create-checkout'
+
+export async function loader({ request }: DataFunctionArgs) {
+ await authenticator.isAuthenticated(request, { failureRedirect: '/login' })
+ return redirect('/account')
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/',
+ })
+
+ const user = await getUserById(session.id)
+ if (!user) return redirect('/login')
+ if (!user.customerId) throw new Error('Unable to get Customer ID.')
+
+ // Get form values.
+ const formData = Object.fromEntries(await request.formData())
+ const formDataParsed = JSON.parse(formData.plan as string)
+ const planId = String(formDataParsed.planId)
+ const planInterval = String(formDataParsed.planInterval)
+
+ if (!planId || !planInterval)
+ throw new Error('Missing required parameters to create Stripe Checkout Session.')
+
+ // Get client's currency.
+ const defaultCurrency = getDefaultCurrency(request)
+
+ // Get price ID for the requested plan.
+ const plan = await getPlanById(planId, { prices: true })
+ const planPrice = plan?.prices.find(
+ (price) => price.interval === planInterval && price.currency === defaultCurrency,
+ )
+ if (!planPrice) throw new Error('Unable to find a Plan price.')
+
+ // Redirect to Checkout.
+ const checkoutUrl = await createStripeCheckoutSession(user.customerId, planPrice.id)
+ return redirect(checkoutUrl)
+}
diff --git a/app/routes/resources+/stripe.create-customer-portal.ts b/app/routes/resources+/stripe.create-customer-portal.ts
new file mode 100644
index 00000000..3621efeb
--- /dev/null
+++ b/app/routes/resources+/stripe.create-customer-portal.ts
@@ -0,0 +1,30 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { redirect, json } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+import { getUserById } from '~/models/user/get-user'
+import { createStripeCustomerPortalSession } from '~/services/stripe/api/create-customer-portal'
+
+export async function loader({ request }: DataFunctionArgs) {
+ await authenticator.isAuthenticated(request, {
+ failureRedirect: '/',
+ })
+ return redirect('/account')
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/',
+ })
+
+ const user = await getUserById(session.id)
+ if (!user) return redirect('/login')
+
+ // Redirect to Customer Portal.
+ if (user.customerId) {
+ const customerPortalUrl = await createStripeCustomerPortalSession(user.customerId)
+ return redirect(customerPortalUrl)
+ }
+
+ return json({}, { status: 400 })
+}
diff --git a/app/routes/resources+/stripe.create-customer.ts b/app/routes/resources+/stripe.create-customer.ts
new file mode 100644
index 00000000..4c396543
--- /dev/null
+++ b/app/routes/resources+/stripe.create-customer.ts
@@ -0,0 +1,30 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { redirect } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+
+import { getUserById } from '~/models/user/get-user'
+import { updateUserById } from '~/models/user/update-user'
+import { createStripeCustomer } from '~/services/stripe/api/create-customer'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/login',
+ })
+
+ const user = await getUserById(session.id)
+ if (!user) return redirect('/login')
+ if (user.customerId) return redirect('/account')
+
+ // Create Stripe Customer.
+ const email = user.email ? user.email : undefined
+ const name = user.name ? user.name : undefined
+
+ const customer = await createStripeCustomer({ email, name })
+ if (!customer) throw new Error('Unable to create Stripe Customer.')
+
+ // Update user.
+ await updateUserById(user.id, { customerId: customer.id })
+
+ return redirect('/account')
+}
diff --git a/app/routes/resources+/stripe.create-subscription.ts b/app/routes/resources+/stripe.create-subscription.ts
new file mode 100644
index 00000000..51c56a85
--- /dev/null
+++ b/app/routes/resources+/stripe.create-subscription.ts
@@ -0,0 +1,57 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { redirect } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+
+import { getUserById } from '~/models/user/get-user'
+import { getPlanById } from '~/models/plan/get-plan'
+import { getSubscriptionByUserId } from '~/models/subscription/get-subscription'
+import { createSubscription } from '~/models/subscription/create-subscription'
+
+import { PlanId } from '~/services/stripe/plans'
+import { createStripeSubscription } from '~/services/stripe/api/create-subscription'
+import { getDefaultCurrency } from '~/utils/locales'
+
+export async function loader({ request }: DataFunctionArgs) {
+ const session = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/login',
+ })
+
+ const user = await getUserById(session.id)
+ if (!user) return redirect('/login')
+
+ const subscription = await getSubscriptionByUserId(user.id)
+ if (subscription?.id) return redirect('/account')
+ if (!user.customerId) throw new Error('Unable to find Customer ID.')
+
+ // Get client's currency and Free Plan price ID.
+ const currency = getDefaultCurrency(request)
+ const freePlan = await getPlanById(PlanId.FREE, { prices: true })
+ const freePlanPrice = freePlan?.prices.find(
+ (price) => price.interval === 'year' && price.currency === currency,
+ )
+ if (!freePlanPrice) throw new Error('Unable to find Free Plan price.')
+
+ // Create Stripe Subscription.
+ const newSubscription = await createStripeSubscription(
+ user.customerId,
+ freePlanPrice.id,
+ )
+ if (!newSubscription) throw new Error('Unable to create Stripe Subscription.')
+
+ // Store Subscription into database.
+ const storedSubscription = await createSubscription({
+ id: newSubscription.id,
+ userId: user.id,
+ planId: String(newSubscription.items.data[0].plan.product),
+ priceId: String(newSubscription.items.data[0].price.id),
+ interval: String(newSubscription.items.data[0].plan.interval),
+ status: newSubscription.status,
+ currentPeriodStart: newSubscription.current_period_start,
+ currentPeriodEnd: newSubscription.current_period_end,
+ cancelAtPeriodEnd: newSubscription.cancel_at_period_end,
+ })
+ if (!storedSubscription) throw new Error('Unable to create Subscription.')
+
+ return redirect('/account')
+}
diff --git a/app/routes/resources+/user.delete.ts b/app/routes/resources+/user.delete.ts
new file mode 100644
index 00000000..c32d6003
--- /dev/null
+++ b/app/routes/resources+/user.delete.ts
@@ -0,0 +1,37 @@
+import type { DataFunctionArgs } from '@remix-run/node'
+
+import { redirect } from '@remix-run/node'
+import { authenticator } from '~/services/auth/config.server'
+import { getSession, destroySession } from '~/services/auth/session.server'
+
+import { getUserById } from '~/models/user/get-user'
+import { deleteUserById } from '~/models/user/delete-user'
+import { deleteStripeCustomer } from '~/services/stripe/api/delete-customer'
+
+export async function loader({ request }: DataFunctionArgs) {
+ await authenticator.isAuthenticated(request, { failureRedirect: '/login' })
+ return redirect('/account')
+}
+
+export async function action({ request }: DataFunctionArgs) {
+ const userSession = await authenticator.isAuthenticated(request, {
+ failureRedirect: '/login',
+ })
+
+ const user = await getUserById(userSession.id)
+ if (!user) return redirect('/login')
+
+ // Delete user from database.
+ await deleteUserById(user.id)
+
+ // Delete Stripe Customer.
+ if (user.customerId) await deleteStripeCustomer(user.customerId)
+
+ // Destroy session.
+ let session = await getSession(request.headers.get('Cookie'))
+ return redirect('/', {
+ headers: {
+ 'Set-Cookie': await destroySession(session),
+ },
+ })
+}
diff --git a/app/services/auth/config.server.ts b/app/services/auth/config.server.ts
new file mode 100644
index 00000000..f794d257
--- /dev/null
+++ b/app/services/auth/config.server.ts
@@ -0,0 +1,141 @@
+import type { User } from '@prisma/client'
+
+import { Authenticator } from 'remix-auth'
+import { OTPStrategy } from 'remix-auth-otp'
+import { SocialsProvider, GoogleStrategy } from 'remix-auth-socials'
+import { sessionStorage } from '~/services/auth/session.server'
+
+import { getUserByEmail } from '~/models/user/get-user'
+import { createUser } from '~/models/user/create-user'
+
+import { db } from '~/utils/db'
+import { HOST_URL } from '~/utils/http'
+import { sendEmail } from '~/services/email/config.server'
+
+/**
+ * Inits Authenticator.
+ */
+export let authenticator = new Authenticator(sessionStorage, {
+ throwOnError: true,
+})
+
+/**
+ * Google - Strategy.
+ */
+authenticator.use(
+ new GoogleStrategy(
+ {
+ clientID: process.env.GOOGLE_CLIENT_ID || '',
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
+ callbackURL: `${HOST_URL}/auth/${SocialsProvider.GOOGLE}/callback`,
+ prompt: 'consent',
+ },
+ async ({ profile }) => {
+ // Get user from database.
+ let user = await getUserByEmail(profile._json.email)
+
+ if (!user) {
+ user = await createUser({ email: profile._json.email })
+ if (!user) throw new Error('Unable to create user.')
+ }
+
+ // Return user as Session.
+ return user
+ },
+ ),
+)
+
+/**
+ * One Time Password - Strategy.
+ */
+authenticator.use(
+ new OTPStrategy(
+ {
+ secret: process.env.ENCRYPTION_SECRET || 'STRONG_SECRET',
+
+ // Magic generation.
+ magicLinkGeneration: {
+ callbackPath: '/auth/magic',
+ },
+
+ // Store code in database.
+ storeCode: async (code) => {
+ await db.otp.create({
+ data: {
+ code: code,
+ active: true,
+ attempts: 0,
+ },
+ })
+ },
+
+ // Send code to user.
+ sendCode: async ({ email, code, magicLink, user, form, request }) => {
+ const sender = { name: 'Remix Auth OTP', email: 'localhost@example.com' }
+ const to = [{ email }]
+ const subject = `Here's your OTP Code!`
+ const htmlContent = `
+
+
+
+
+
+
+ Code: ${code}
+ ${
+ magicLink &&
+ `
+ Alternatively, you can click the Magic Link URL.
+
+ ${magicLink}
+
`
+ }
+
+
+ `
+
+ // Call provider sender email function.
+ await sendEmail({ sender, to, subject, htmlContent })
+ },
+
+ // Validate code.
+ validateCode: async (code) => {
+ const otp = await db.otp.findUnique({
+ where: { code: code },
+ })
+ if (!otp) throw new Error('Code not found.')
+
+ return {
+ code: otp.code,
+ active: otp.active,
+ attempts: otp.attempts,
+ }
+ },
+
+ // Invalidate code.
+ invalidateCode: async (code, active, attempts) => {
+ await db.otp.update({
+ where: {
+ code: code,
+ },
+ data: {
+ active: active,
+ attempts: attempts,
+ },
+ })
+ },
+ },
+ async ({ email }) => {
+ // Get user from database.
+ let user = await getUserByEmail(email)
+
+ if (!user) {
+ user = await createUser({ email })
+ if (!user) throw new Error('Unable to create user.')
+ }
+
+ // Return user as Session.
+ return user
+ },
+ ),
+)
diff --git a/app/services/auth/session.server.ts b/app/services/auth/session.server.ts
new file mode 100644
index 00000000..82308d87
--- /dev/null
+++ b/app/services/auth/session.server.ts
@@ -0,0 +1,14 @@
+import { createCookieSessionStorage } from '@remix-run/node'
+
+export const sessionStorage = createCookieSessionStorage({
+ cookie: {
+ name: '_session',
+ sameSite: 'lax',
+ path: '/',
+ httpOnly: true,
+ secrets: [process.env.SESSION_SECRET || 'STRONG_SECRET'],
+ secure: process.env.NODE_ENV === 'production',
+ },
+})
+
+export const { getSession, commitSession, destroySession } = sessionStorage
diff --git a/app/services/email/config.server.ts b/app/services/email/config.server.ts
new file mode 100644
index 00000000..04135bba
--- /dev/null
+++ b/app/services/email/config.server.ts
@@ -0,0 +1,29 @@
+export type SendEmailBody = {
+ sender: {
+ name: string
+ email: string
+ }
+ to: {
+ name?: string
+ email: string
+ }[]
+ subject: string
+ htmlContent: string
+}
+
+export const sendEmail = async (body: SendEmailBody) => {
+ try {
+ return fetch(`https://api.sendinblue.com/v3/smtp/email`, {
+ method: 'post',
+ headers: {
+ Accept: 'application/json',
+ 'Api-Key': process.env.EMAIL_PROVIDER_API_KEY,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ ...body }),
+ })
+ } catch (err: unknown) {
+ console.log(err)
+ return false
+ }
+}
diff --git a/app/services/stripe/api/configure-customer-portal.ts b/app/services/stripe/api/configure-customer-portal.ts
new file mode 100644
index 00000000..dd2db760
--- /dev/null
+++ b/app/services/stripe/api/configure-customer-portal.ts
@@ -0,0 +1,34 @@
+import type { PlanId } from '../plans'
+import { stripe } from '~/services/stripe/config.server'
+
+type BillingPortalProducts = {
+ product: PlanId
+ prices: string[]
+}
+
+export async function configureStripeCustomerPortal(products: BillingPortalProducts[]) {
+ if (!products)
+ throw new Error('Missing required parameters to configure Stripe Customer Portal.')
+
+ return stripe.billingPortal.configurations.create({
+ business_profile: {
+ headline: 'Organization Name - Customer Portal',
+ },
+ features: {
+ customer_update: {
+ enabled: true,
+ allowed_updates: ['address', 'shipping', 'tax_id', 'email'],
+ },
+ invoice_history: { enabled: true },
+ payment_method_update: { enabled: true },
+ subscription_pause: { enabled: false },
+ subscription_cancel: { enabled: true },
+ subscription_update: {
+ enabled: true,
+ default_allowed_updates: ['price'],
+ proration_behavior: 'always_invoice',
+ products: products.filter(({ product }) => product !== 'free'),
+ },
+ },
+ })
+}
diff --git a/app/services/stripe/api/create-checkout.ts b/app/services/stripe/api/create-checkout.ts
new file mode 100644
index 00000000..bb22ab98
--- /dev/null
+++ b/app/services/stripe/api/create-checkout.ts
@@ -0,0 +1,27 @@
+import type { Stripe } from 'stripe'
+import type { User, Price } from '@prisma/client'
+
+import { stripe } from '~/services/stripe/config.server'
+import { HOST_URL } from '~/utils/http'
+
+export async function createStripeCheckoutSession(
+ customerId: User['customerId'],
+ priceId: Price['id'],
+ params?: Stripe.Checkout.SessionCreateParams,
+) {
+ if (!customerId || !priceId)
+ throw new Error('Missing required parameters to create Stripe Checkout Session.')
+
+ const session = await stripe.checkout.sessions.create({
+ customer: customerId,
+ line_items: [{ price: priceId, quantity: 1 }],
+ mode: 'subscription',
+ payment_method_types: ['card'],
+ success_url: `${HOST_URL}/checkout`,
+ cancel_url: `${HOST_URL}/plans`,
+ ...params,
+ })
+ if (!session?.url) throw new Error('Unable to create Stripe Checkout Session.')
+
+ return session.url
+}
diff --git a/app/services/stripe/api/create-customer-portal.ts b/app/services/stripe/api/create-customer-portal.ts
new file mode 100644
index 00000000..1ce6a0ae
--- /dev/null
+++ b/app/services/stripe/api/create-customer-portal.ts
@@ -0,0 +1,17 @@
+import type { User } from '@prisma/client'
+
+import { stripe } from '~/services/stripe/config.server'
+import { HOST_URL } from '~/utils/http'
+
+export async function createStripeCustomerPortalSession(customerId: User['customerId']) {
+ if (!customerId)
+ throw new Error('Missing required parameters to create Stripe Customer Portal.')
+
+ const session = await stripe.billingPortal.sessions.create({
+ customer: customerId,
+ return_url: `${HOST_URL}/resources/stripe/create-customer-portal`,
+ })
+ if (!session?.url) throw new Error('Unable to create Stripe Customer Portal Session.')
+
+ return session.url
+}
diff --git a/app/services/stripe/api/create-customer.ts b/app/services/stripe/api/create-customer.ts
new file mode 100644
index 00000000..63d54cac
--- /dev/null
+++ b/app/services/stripe/api/create-customer.ts
@@ -0,0 +1,7 @@
+import type { Stripe } from 'stripe'
+import { stripe } from '~/services/stripe/config.server'
+
+export async function createStripeCustomer(customer?: Stripe.CustomerCreateParams) {
+ if (!customer) throw new Error('No customer data provided.')
+ return stripe.customers.create(customer)
+}
diff --git a/app/services/stripe/api/create-price.ts b/app/services/stripe/api/create-price.ts
new file mode 100644
index 00000000..45bf7c7c
--- /dev/null
+++ b/app/services/stripe/api/create-price.ts
@@ -0,0 +1,24 @@
+import type { Stripe } from 'stripe'
+import type { Plan, Price } from '@prisma/client'
+import type { Interval } from '~/services/stripe/plans'
+import { stripe } from '~/services/stripe/config.server'
+
+export async function createStripePrice(
+ id: Plan['id'],
+ price: Partial,
+ params?: Stripe.PriceCreateParams,
+) {
+ if (!id || !price)
+ throw new Error('Missing required parameters to create Stripe Price.')
+
+ return stripe.prices.create({
+ ...params,
+ product: id,
+ currency: price.currency ?? 'usd',
+ unit_amount: price.amount ?? 0,
+ tax_behavior: 'inclusive',
+ recurring: {
+ interval: (price.interval as Interval) ?? 'month',
+ },
+ })
+}
diff --git a/app/services/stripe/api/create-product.ts b/app/services/stripe/api/create-product.ts
new file mode 100644
index 00000000..47a6ba5d
--- /dev/null
+++ b/app/services/stripe/api/create-product.ts
@@ -0,0 +1,18 @@
+import type { Plan } from '@prisma/client'
+import type { Stripe } from 'stripe'
+import { stripe } from '~/services/stripe/config.server'
+
+export async function createStripeProduct(
+ product: Partial,
+ params?: Stripe.ProductCreateParams,
+) {
+ if (!product || !product.id || !product.name)
+ throw new Error('Missing required parameters to create Stripe Product.')
+
+ return stripe.products.create({
+ ...params,
+ id: product.id,
+ name: product.name,
+ description: product.description || undefined,
+ })
+}
diff --git a/app/services/stripe/api/create-subscription.ts b/app/services/stripe/api/create-subscription.ts
new file mode 100644
index 00000000..f64105ae
--- /dev/null
+++ b/app/services/stripe/api/create-subscription.ts
@@ -0,0 +1,18 @@
+import type { Stripe } from 'stripe'
+import type { User, Price } from '@prisma/client'
+import { stripe } from '~/services/stripe/config.server'
+
+export async function createStripeSubscription(
+ customerId: User['customerId'],
+ price: Price['id'],
+ params?: Stripe.SubscriptionCreateParams,
+) {
+ if (!customerId || !price)
+ throw new Error('Missing required parameters to create Stripe Subscription.')
+
+ return stripe.subscriptions.create({
+ ...params,
+ customer: customerId,
+ items: [{ price }],
+ })
+}
diff --git a/app/services/stripe/api/delete-customer.ts b/app/services/stripe/api/delete-customer.ts
new file mode 100644
index 00000000..6e5f03dc
--- /dev/null
+++ b/app/services/stripe/api/delete-customer.ts
@@ -0,0 +1,9 @@
+import type { User } from '@prisma/client'
+import { stripe } from '~/services/stripe/config.server'
+
+export async function deleteStripeCustomer(customerId?: User['customerId']) {
+ if (!customerId)
+ throw new Error('Missing required parameters to delete Stripe Customer.')
+
+ return stripe.customers.del(customerId)
+}
diff --git a/app/services/stripe/api/retrieve-subscription.ts b/app/services/stripe/api/retrieve-subscription.ts
new file mode 100644
index 00000000..c7bc29ba
--- /dev/null
+++ b/app/services/stripe/api/retrieve-subscription.ts
@@ -0,0 +1,11 @@
+import type { Subscription } from '@prisma/client'
+import type { Stripe } from 'stripe'
+import { stripe } from '~/services/stripe/config.server'
+
+export async function retrieveStripeSubscription(
+ id?: Subscription['id'],
+ params?: Stripe.SubscriptionRetrieveParams,
+) {
+ if (!id) throw new Error('Missing required parameters to retrieve Stripe Subscription.')
+ return stripe.subscriptions.retrieve(id, params)
+}
diff --git a/app/services/stripe/config.server.ts b/app/services/stripe/config.server.ts
new file mode 100644
index 00000000..7364c69e
--- /dev/null
+++ b/app/services/stripe/config.server.ts
@@ -0,0 +1,8 @@
+import Stripe from 'stripe'
+
+if (!process.env.STRIPE_SECRET_KEY) throw new Error('Missing STRIPE_SECRET_KEY.')
+
+export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
+ apiVersion: '2022-11-15',
+ typescript: true,
+})
diff --git a/app/services/stripe/plans.ts b/app/services/stripe/plans.ts
new file mode 100644
index 00000000..b264ee37
--- /dev/null
+++ b/app/services/stripe/plans.ts
@@ -0,0 +1,110 @@
+import type { PlanLimit, Price } from '@prisma/client'
+
+/**
+ * Defines our plans IDs.
+ */
+export const enum PlanId {
+ FREE = 'free',
+ STARTER = 'starter',
+ PRO = 'pro',
+}
+
+/**
+ * Defines our plan pricing intervals.
+ */
+export const enum Interval {
+ MONTH = 'month',
+ YEAR = 'year',
+}
+
+/**
+ * Defines our plan pricing currencies.
+ */
+export const enum Currency {
+ DEFAULT_CURRENCY = 'usd',
+ USD = 'usd',
+ EUR = 'eur',
+}
+
+/**
+ * Defines our plans structure.
+ */
+export const PRICING_PLANS = {
+ [PlanId.FREE]: {
+ id: PlanId.FREE,
+ name: 'Free',
+ description: 'Free Plan Description',
+ features: ['1 Star per Minute', 'Limited to 9 Stars'],
+ limits: { maxItems: 9 },
+ prices: {
+ [Interval.MONTH]: {
+ [Currency.USD]: 0,
+ [Currency.EUR]: 0,
+ },
+ [Interval.YEAR]: {
+ [Currency.USD]: 0,
+ [Currency.EUR]: 0,
+ },
+ },
+ },
+ [PlanId.STARTER]: {
+ id: PlanId.STARTER,
+ name: 'Starter',
+ description: 'Starter Plan Description',
+ features: ['4 Stars per Minute', 'Limited to 99 Stars'],
+ limits: { maxItems: 99 },
+ prices: {
+ [Interval.MONTH]: {
+ [Currency.USD]: 990,
+ [Currency.EUR]: 990,
+ },
+ [Interval.YEAR]: {
+ [Currency.USD]: 9990,
+ [Currency.EUR]: 9990,
+ },
+ },
+ },
+ [PlanId.PRO]: {
+ id: PlanId.PRO,
+ name: 'Pro',
+ description: 'Pro Plan Description',
+ features: ['8 Stars per Minute', 'Limited to 999 Stars'],
+ limits: { maxItems: 999 },
+ prices: {
+ [Interval.MONTH]: {
+ [Currency.USD]: 1990,
+ [Currency.EUR]: 1990,
+ },
+ [Interval.YEAR]: {
+ [Currency.USD]: 19990,
+ [Currency.EUR]: 19990,
+ },
+ },
+ },
+} satisfies PricingPlan
+
+/**
+ * A helper type that defines our price by interval.
+ */
+export type PriceInterval<
+ I extends Interval = Interval,
+ C extends Currency = Currency,
+> = {
+ [interval in I]: {
+ [currency in C]: Price['amount']
+ }
+}
+
+/**
+ * A helper type that defines our pricing plans structure by Interval.
+ */
+export type PricingPlan = {
+ [key in T]: {
+ id: string
+ name: string
+ description: string
+ features: string[]
+ limits: Pick
+ prices: PriceInterval
+ }
+}
diff --git a/app/utils/date.ts b/app/utils/date.ts
new file mode 100644
index 00000000..3df65ea8
--- /dev/null
+++ b/app/utils/date.ts
@@ -0,0 +1,20 @@
+import dayjs from 'dayjs'
+import IsSameOrAfter from 'dayjs/plugin/isSameOrAfter'
+import LocalizedFormat from 'dayjs/plugin/localizedFormat'
+
+export function formatUnixDate(unixDate: number) {
+ // Extend DayJS module.
+ dayjs.extend(LocalizedFormat)
+
+ if (typeof unixDate === 'number') return dayjs.unix(unixDate).format('LLL')
+}
+
+export function hasUnixDateExpired(date: number) {
+ // Extend DayJS module.
+ dayjs.extend(IsSameOrAfter)
+
+ const unixDate = dayjs.unix(date)
+ const hasExpired = dayjs().isSameOrAfter(unixDate, 'm')
+
+ return hasExpired
+}
diff --git a/app/utils/db.ts b/app/utils/db.ts
new file mode 100644
index 00000000..cbc83960
--- /dev/null
+++ b/app/utils/db.ts
@@ -0,0 +1,17 @@
+import { PrismaClient } from '@prisma/client'
+
+let db: PrismaClient
+
+declare global {
+ var __db__: PrismaClient
+}
+
+if (process.env.NODE_ENV === 'production') {
+ db = new PrismaClient()
+} else {
+ if (!global.__db__) global.__db__ = new PrismaClient()
+ db = global.__db__
+ db.$connect()
+}
+
+export { db }
diff --git a/app/utils/envs.ts b/app/utils/envs.ts
new file mode 100644
index 00000000..eb29130d
--- /dev/null
+++ b/app/utils/envs.ts
@@ -0,0 +1,49 @@
+declare global {
+ var ENV: ENV
+
+ interface Window {
+ ENV: {
+ NODE_ENV: 'development' | 'production' | 'test'
+ DEV_HOST_URL: string
+ PROD_HOST_URL: string
+ }
+ }
+}
+
+declare global {
+ namespace NodeJS {
+ interface ProcessEnv {
+ NODE_ENV: 'development' | 'production' | 'test'
+ SESSION_SECRET: string
+ ENCRYPTION_SECRET: string
+ DATABASE_URL: string
+
+ DEV_HOST_URL: string
+ PROD_HOST_URL: string
+
+ EMAIL_PROVIDER_API_KEY: string
+ GOOGLE_CLIENT_ID: string
+ GOOGLE_CLIENT_SECRET: string
+
+ STRIPE_PUBLIC_KEY: string
+ STRIPE_SECRET_KEY: string
+ DEV_STRIPE_WEBHOOK_ENDPOINT: string
+ PROD_STRIPE_WEBHOOK_ENDPOINT: string
+ }
+ }
+}
+
+/**
+ * Exports shared environment variables.
+ *
+ * Shared envs are used in both `entry.server.ts` and `root.tsx`.
+ * Do not share sensible variables that you do not wish to be included in the client.
+ */
+export function getSharedEnvs() {
+ return {
+ DEV_HOST_URL: process.env.DEV_HOST_URL,
+ PROD_HOST_URL: process.env.PROD_HOST_URL,
+ }
+}
+
+type ENV = ReturnType
diff --git a/app/utils/hooks.ts b/app/utils/hooks.ts
new file mode 100644
index 00000000..95d3d418
--- /dev/null
+++ b/app/utils/hooks.ts
@@ -0,0 +1,24 @@
+import { useEffect, useRef } from 'react'
+
+/**
+ * Declarative interval.
+ * More info: https://overreacted.io/making-setinterval-declarative-with-react-hooks
+ */
+export function useInterval(callback: () => void, delay: number | null) {
+ const savedCallback = useRef<() => void>()
+
+ useEffect(() => {
+ savedCallback.current = callback
+ }, [callback])
+
+ useEffect(() => {
+ function tick() {
+ savedCallback.current?.()
+ }
+
+ if (delay) {
+ const id = setInterval(tick, delay)
+ return () => clearInterval(id)
+ }
+ }, [delay])
+}
diff --git a/app/utils/http.ts b/app/utils/http.ts
new file mode 100644
index 00000000..f31acb39
--- /dev/null
+++ b/app/utils/http.ts
@@ -0,0 +1,4 @@
+export const HOST_URL =
+ process.env.NODE_ENV === 'development'
+ ? process.env.DEV_HOST_URL
+ : process.env.PROD_HOST_URL
diff --git a/app/utils/locales.ts b/app/utils/locales.ts
new file mode 100644
index 00000000..4a0899eb
--- /dev/null
+++ b/app/utils/locales.ts
@@ -0,0 +1,11 @@
+import { getClientLocales } from 'remix-utils'
+import { Currency } from '~/services/stripe/plans'
+
+export function getDefaultCurrency(request: Request) {
+ const locales = getClientLocales(request)
+
+ // Set a default currency if no locales are found.
+ if (!locales) return Currency.DEFAULT_CURRENCY
+
+ return locales?.find((locale) => locale === 'en-US') ? Currency.USD : Currency.EUR
+}
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 00000000..62394361
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,52 @@
+app = "stripe-stack-dev"
+
+kill_signal = "SIGINT"
+kill_timeout = 5
+processes = []
+
+[experimental]
+ allowed_public_ports = []
+ auto_rollback = true
+ cmd = "start.sh"
+ entrypoint = "sh"
+
+[mounts]
+ destination = "/data"
+ source = "data"
+
+[[services]]
+ internal_port = 8080
+ processes = ["app"]
+ protocol = "tcp"
+ script_checks = []
+
+ [services.concurrency]
+ hard_limit = 25
+ soft_limit = 20
+ type = "connections"
+
+ [[services.ports]]
+ force_https = true
+ handlers = ["http"]
+ port = 80
+
+ [[services.ports]]
+ handlers = ["tls", "http"]
+ port = 443
+
+ [[services.tcp_checks]]
+ grace_period = "1s"
+ interval = "15s"
+ restart_limit = 0
+ timeout = "2s"
+
+ [[services.http_checks]]
+ grace_period = "5s"
+ interval = "30s"
+ method = "get"
+ path = "/api/healthcheck"
+ protocol = "http"
+ timeout = "2s"
+ tls_skip_verify = false
+ [services.http_checks.headers]
+
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..eb501968
--- /dev/null
+++ b/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "stripe-stack-dev",
+ "author": "https://github.com/dev-xo",
+ "private": true,
+ "sideEffects": false,
+ "scripts": {
+ "build": "remix build",
+ "dev": "remix dev",
+ "start": "remix-serve build",
+ "format": "prettier --write .",
+ "typecheck": "tsc -b",
+ "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
+ "test:e2e:dev": "playwright test",
+ "test:e2e:run": "cross-env CI=true playwright test",
+ "test:e2e:install": "npx playwright install chromium --with-deps",
+ "pretest:e2e:run": "npm run build",
+ "validate": "npm run lint && npm run typecheck && npm run test:e2e:run",
+ "seed:build": "tsc --project ./tsconfig.seed.json && tsc-alias -p ./tsconfig.seed.json"
+ },
+ "dependencies": {
+ "@prisma/client": "^4.16.2",
+ "@remix-run/node": "^1.18.1",
+ "@remix-run/react": "^1.18.1",
+ "@remix-run/serve": "^1.18.1",
+ "autoprefixer": "^10.4.14",
+ "dayjs": "^1.11.9",
+ "isbot": "^3.6.12",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "remix-auth": "^3.5.0",
+ "remix-auth-otp": "^2.3.0",
+ "remix-auth-socials": "^2.0.5",
+ "remix-utils": "^6.6.0",
+ "stripe": "^11.18.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.36.1",
+ "@remix-run/dev": "^1.18.1",
+ "@remix-run/eslint-config": "^1.18.1",
+ "@types/eslint": "^8.44.0",
+ "@types/react": "^18.2.15",
+ "@types/react-dom": "^18.2.7",
+ "cross-env": "^7.0.3",
+ "eslint": "^8.45.0",
+ "eslint-config-prettier": "^8.8.0",
+ "prettier": "^2.8.8",
+ "prettier-plugin-tailwindcss": "^0.2.8",
+ "prisma": "^4.16.2",
+ "remix-flat-routes": "^0.5.10",
+ "stripe-event-types": "^2.4.0",
+ "tailwindcss": "^3.3.3",
+ "ts-node": "^10.9.1",
+ "tsc-alias": "^1.8.7",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "^4.9.5"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "prisma": {
+ "seed": "ts-node --require tsconfig-paths/register prisma/seed.ts"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 00000000..eefe87f1
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,83 @@
+import type { PlaywrightTestConfig } from '@playwright/test'
+import { devices } from '@playwright/test'
+
+/**
+ * Playwright Environment Variables.
+ * @see https://github.com/motdotla/dotenv
+ *
+ * require('dotenv').config();
+ */
+
+/**
+ * Playwright Config.
+ * @see https://playwright.dev/docs/test-configuration.
+ */
+const config: PlaywrightTestConfig = {
+ // Inits global setup.
+ // globalSetup: require.resolve('./tests/setup-playwright.ts'),
+
+ // Directory that will be recursively scanned for test files.
+ testDir: './tests/e2e',
+
+ // Maximum time one test can run for.
+ timeout: 30 * 1000,
+
+ expect: {
+ // Maximum time expect() should wait for the condition to be met.
+ // For example in `await expect(locator).toHaveText();`
+ timeout: 5000,
+ },
+
+ // Run tests in files in parallel.
+ fullyParallel: true,
+
+ // Fail the build on CI if you accidentally left test.only in the source code.
+ forbidOnly: !!process.env.CI,
+
+ // Retry on CI only
+ retries: process.env.CI ? 2 : 0,
+
+ // Opt out of parallel tests on CI.
+ workers: process.env.CI ? 1 : undefined,
+
+ // Reporter to use. See https://playwright.dev/docs/test-reporters
+ reporter: 'html',
+
+ // Shared settings for all the projects below.
+ // See https://playwright.dev/docs/api/class-testoptions.
+ use: {
+ // Tells all tests to load signed-in state from 'my-file.json'.
+ // storageState: './tests/auth-storage.json',
+
+ // Maximum time each action such as `click()` can take. Defaults to 0 (no limit).
+ actionTimeout: 0,
+
+ // Base URL to use in actions like `await page.goto('/')`.
+ baseURL: 'http://localhost:8811/',
+
+ // Collect trace when retrying the failed test.
+ // See https://playwright.dev/docs/trace-viewer
+ trace: 'on-first-retry',
+ },
+
+ // Configure projects for major browsers.
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ },
+ },
+ ],
+
+ // Folder for test artifacts such as screenshots, videos, traces, etc.
+ // outputDir: 'test-results/',
+
+ // Run your local dev server before starting the tests.
+ webServer: {
+ command: 'cross-env PORT=8811 npm run dev',
+ port: 8811,
+ },
+}
+
+export default config
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 00000000..33ad091d
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
new file mode 100644
index 00000000..c2e4f302
--- /dev/null
+++ b/prisma/schema.prisma
@@ -0,0 +1,102 @@
+// Prisma Schema.
+// Learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+// Disclaimer: This is a Stripe demo schema.
+// It can be used in production, but you will have to adapt it to your own needs.
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "sqlite"
+ url = env("DATABASE_URL")
+}
+
+// ...
+// Authentication Related Models.
+// ...
+
+model Otp {
+ id String @id @default(cuid())
+ code String @unique
+ active Boolean @default(false)
+ attempts Int @default(0)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model User {
+ id String @unique @default(cuid())
+ email String @unique
+ name String?
+ customerId String? @unique
+ subscription Subscription?
+
+ createdAt DateTime? @default(now())
+ updatedAt DateTime? @updatedAt
+}
+
+// ...
+// Subscription Related Models.
+// ...
+
+// Plans are used to describe and group our Stripe Products.
+model Plan {
+ id String @id @unique
+ name String
+ description String?
+ active Boolean? @default(true)
+ limits PlanLimit?
+ prices Price[]
+ subscriptions Subscription[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+// Plan limits are used to describe the limits available to a plan.
+model PlanLimit {
+ id String @id @default(cuid())
+ plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ planId String @unique
+
+ // Here you can define your own limits.
+ // For example, you could have a limit on the number of items a user can create.
+ maxItems Int @default(0)
+}
+
+// Prices are used to identify our plan prices.
+model Price {
+ id String @id @unique // Managed by Stripe - (Price ID)
+ plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ planId String
+ amount Int
+ currency String
+ interval String
+ active Boolean @default(true)
+ subscriptions Subscription[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+// Subscriptions are used to identify our customers subscriptions.
+model Subscription {
+ id String @id @unique // Managed by Stripe - (Subscription ID)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ userId String @unique
+ plan Plan @relation(fields: [planId], references: [id])
+ planId String
+ price Price @relation(fields: [priceId], references: [id])
+ priceId String
+ interval String
+ status String
+ currentPeriodStart Int
+ currentPeriodEnd Int
+ cancelAtPeriodEnd Boolean @default(false)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
diff --git a/prisma/seed.ts b/prisma/seed.ts
new file mode 100644
index 00000000..311264cc
--- /dev/null
+++ b/prisma/seed.ts
@@ -0,0 +1,95 @@
+import { PrismaClient } from '@prisma/client'
+import { db } from '~/utils/db'
+
+import { getAllPlans } from '~/models/plan/get-plan'
+import { PRICING_PLANS } from '~/services/stripe/plans'
+import { createStripeProduct } from '~/services/stripe/api/create-product'
+import { createStripePrice } from '~/services/stripe/api/create-price'
+import { configureStripeCustomerPortal } from '~/services/stripe/api/configure-customer-portal'
+
+const prisma = new PrismaClient()
+
+async function seed() {
+ const plans = await getAllPlans()
+
+ if (plans.length > 0) {
+ console.log('๐ Plans has already been seeded.')
+ return true
+ }
+
+ const seedProducts = Object.values(PRICING_PLANS).map(
+ async ({ id, name, description, features, limits, prices }) => {
+ // Format prices to match Stripe's API.
+ const pricesByInterval = Object.entries(prices).flatMap(([interval, price]) => {
+ return Object.entries(price).map(([currency, amount]) => ({
+ interval,
+ currency,
+ amount,
+ }))
+ })
+
+ // Create Stripe product.
+ await createStripeProduct({
+ id,
+ name,
+ description: description || undefined,
+ })
+
+ // Create Stripe price for the current product.
+ const stripePrices = await Promise.all(
+ pricesByInterval.map((price) => {
+ return createStripePrice(id, price)
+ }),
+ )
+
+ // Store product into database.
+ await db.plan.create({
+ data: {
+ id,
+ name,
+ description,
+ limits: {
+ create: {
+ maxItems: limits.maxItems,
+ },
+ },
+ prices: {
+ create: stripePrices.map((price) => ({
+ id: price.id,
+ amount: price.unit_amount ?? 0,
+ currency: price.currency,
+ interval: price.recurring?.interval ?? 'month',
+ })),
+ },
+ },
+ })
+
+ // Return product ID and prices.
+ // Used to configure the Customer Portal.
+ return {
+ product: id,
+ prices: stripePrices.map((price) => price.id),
+ }
+ },
+ )
+
+ // Create Stripe products and stores them into database.
+ const seededProducts = await Promise.all(seedProducts)
+ console.log(`๐ฆ Stripe Products has been successfully created.`)
+
+ // Configure Customer Portal.
+ await configureStripeCustomerPortal(seededProducts)
+ console.log(`๐ Stripe Customer Portal has been successfully configured.`)
+ console.log(
+ '๐ Visit: https://dashboard.stripe.com/test/products to see your products.',
+ )
+}
+
+seed()
+ .catch((err: unknown) => {
+ console.error(err)
+ process.exit(1)
+ })
+ .finally(async () => {
+ await prisma.$disconnect()
+ })
diff --git a/prisma/seed/app/models/plan/get-plan.js b/prisma/seed/app/models/plan/get-plan.js
new file mode 100644
index 00000000..f6225787
--- /dev/null
+++ b/prisma/seed/app/models/plan/get-plan.js
@@ -0,0 +1,23 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.getAllPlans = exports.getPlanById = void 0;
+const db_1 = require("../../utils/db");
+async function getPlanById(id, include) {
+ return db_1.db.plan.findUnique({
+ where: { id },
+ include: {
+ ...include,
+ prices: (include === null || include === void 0 ? void 0 : include.prices) || false,
+ },
+ });
+}
+exports.getPlanById = getPlanById;
+async function getAllPlans(include) {
+ return db_1.db.plan.findMany({
+ include: {
+ ...include,
+ prices: (include === null || include === void 0 ? void 0 : include.prices) || false,
+ },
+ });
+}
+exports.getAllPlans = getAllPlans;
diff --git a/prisma/seed/app/services/stripe/api/configure-customer-portal.js b/prisma/seed/app/services/stripe/api/configure-customer-portal.js
new file mode 100644
index 00000000..7ebf9be3
--- /dev/null
+++ b/prisma/seed/app/services/stripe/api/configure-customer-portal.js
@@ -0,0 +1,30 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.configureStripeCustomerPortal = void 0;
+const config_server_1 = require("../../../services/stripe/config.server");
+async function configureStripeCustomerPortal(products) {
+ if (!products)
+ throw new Error('Missing required parameters to configure Stripe Customer Portal.');
+ return config_server_1.stripe.billingPortal.configurations.create({
+ business_profile: {
+ headline: 'Organization Name - Customer Portal',
+ },
+ features: {
+ customer_update: {
+ enabled: true,
+ allowed_updates: ['address', 'shipping', 'tax_id', 'email'],
+ },
+ invoice_history: { enabled: true },
+ payment_method_update: { enabled: true },
+ subscription_pause: { enabled: false },
+ subscription_cancel: { enabled: true },
+ subscription_update: {
+ enabled: true,
+ default_allowed_updates: ['price'],
+ proration_behavior: 'always_invoice',
+ products: products.filter(({ product }) => product !== 'free'),
+ },
+ },
+ });
+}
+exports.configureStripeCustomerPortal = configureStripeCustomerPortal;
diff --git a/prisma/seed/app/services/stripe/api/create-price.js b/prisma/seed/app/services/stripe/api/create-price.js
new file mode 100644
index 00000000..761474a3
--- /dev/null
+++ b/prisma/seed/app/services/stripe/api/create-price.js
@@ -0,0 +1,20 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.createStripePrice = void 0;
+const config_server_1 = require("../../../services/stripe/config.server");
+async function createStripePrice(id, price, params) {
+ var _a, _b, _c;
+ if (!id || !price)
+ throw new Error('Missing required parameters to create Stripe Price.');
+ return config_server_1.stripe.prices.create({
+ ...params,
+ product: id,
+ currency: (_a = price.currency) !== null && _a !== void 0 ? _a : 'usd',
+ unit_amount: (_b = price.amount) !== null && _b !== void 0 ? _b : 0,
+ tax_behavior: 'inclusive',
+ recurring: {
+ interval: (_c = price.interval) !== null && _c !== void 0 ? _c : 'month',
+ },
+ });
+}
+exports.createStripePrice = createStripePrice;
diff --git a/prisma/seed/app/services/stripe/api/create-product.js b/prisma/seed/app/services/stripe/api/create-product.js
new file mode 100644
index 00000000..854496a6
--- /dev/null
+++ b/prisma/seed/app/services/stripe/api/create-product.js
@@ -0,0 +1,15 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.createStripeProduct = void 0;
+const config_server_1 = require("../../../services/stripe/config.server");
+async function createStripeProduct(product, params) {
+ if (!product || !product.id || !product.name)
+ throw new Error('Missing required parameters to create Stripe Product.');
+ return config_server_1.stripe.products.create({
+ ...params,
+ id: product.id,
+ name: product.name,
+ description: product.description || undefined,
+ });
+}
+exports.createStripeProduct = createStripeProduct;
diff --git a/prisma/seed/app/services/stripe/config.server.js b/prisma/seed/app/services/stripe/config.server.js
new file mode 100644
index 00000000..84e591a2
--- /dev/null
+++ b/prisma/seed/app/services/stripe/config.server.js
@@ -0,0 +1,13 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.stripe = void 0;
+const stripe_1 = __importDefault(require("stripe"));
+if (!process.env.STRIPE_SECRET_KEY)
+ throw new Error('Missing Stripe secret key.');
+exports.stripe = new stripe_1.default(process.env.STRIPE_SECRET_KEY, {
+ apiVersion: '2022-11-15',
+ typescript: true,
+});
diff --git a/prisma/seed/app/services/stripe/plans.js b/prisma/seed/app/services/stripe/plans.js
new file mode 100644
index 00000000..429460ea
--- /dev/null
+++ b/prisma/seed/app/services/stripe/plans.js
@@ -0,0 +1,59 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.PRICING_PLANS = void 0;
+/**
+ * Defines our plans structure.
+ */
+exports.PRICING_PLANS = {
+ ["free" /* PlanId.FREE */]: {
+ id: "free" /* PlanId.FREE */,
+ name: 'Free',
+ description: 'Free Plan Description',
+ features: ['1 Star per Minute', 'Limited to 9 Stars'],
+ limits: { maxItems: 9 },
+ prices: {
+ ["month" /* Interval.MONTH */]: {
+ ["usd" /* Currency.USD */]: 0,
+ ["eur" /* Currency.EUR */]: 0,
+ },
+ ["year" /* Interval.YEAR */]: {
+ ["usd" /* Currency.USD */]: 0,
+ ["eur" /* Currency.EUR */]: 0,
+ },
+ },
+ },
+ ["starter" /* PlanId.STARTER */]: {
+ id: "starter" /* PlanId.STARTER */,
+ name: 'Starter',
+ description: 'Starter Plan Description',
+ features: ['4 Stars per Minute', 'Limited to 99 Stars'],
+ limits: { maxItems: 99 },
+ prices: {
+ ["month" /* Interval.MONTH */]: {
+ ["usd" /* Currency.USD */]: 990,
+ ["eur" /* Currency.EUR */]: 990,
+ },
+ ["year" /* Interval.YEAR */]: {
+ ["usd" /* Currency.USD */]: 9990,
+ ["eur" /* Currency.EUR */]: 9990,
+ },
+ },
+ },
+ ["pro" /* PlanId.PRO */]: {
+ id: "pro" /* PlanId.PRO */,
+ name: 'Pro',
+ description: 'Pro Plan Description',
+ features: ['8 Stars per Minute', 'Limited to 999 Stars'],
+ limits: { maxItems: 999 },
+ prices: {
+ ["month" /* Interval.MONTH */]: {
+ ["usd" /* Currency.USD */]: 1990,
+ ["eur" /* Currency.EUR */]: 1990,
+ },
+ ["year" /* Interval.YEAR */]: {
+ ["usd" /* Currency.USD */]: 19990,
+ ["eur" /* Currency.EUR */]: 19990,
+ },
+ },
+ },
+};
diff --git a/prisma/seed/app/utils/db.js b/prisma/seed/app/utils/db.js
new file mode 100644
index 00000000..c9584bc1
--- /dev/null
+++ b/prisma/seed/app/utils/db.js
@@ -0,0 +1,15 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.db = void 0;
+const client_1 = require("@prisma/client");
+let db;
+exports.db = db;
+if (process.env.NODE_ENV === 'production') {
+ exports.db = db = new client_1.PrismaClient();
+}
+else {
+ if (!global.__db__)
+ global.__db__ = new client_1.PrismaClient();
+ exports.db = db = global.__db__;
+ db.$connect();
+}
diff --git a/prisma/seed/prisma/seed.js b/prisma/seed/prisma/seed.js
new file mode 100644
index 00000000..b8b1deba
--- /dev/null
+++ b/prisma/seed/prisma/seed.js
@@ -0,0 +1,79 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+const client_1 = require("@prisma/client");
+const db_1 = require("../app/utils/db");
+const get_plan_1 = require("../app/models/plan/get-plan");
+const plans_1 = require("../app/services/stripe/plans");
+const create_product_1 = require("../app/services/stripe/api/create-product");
+const create_price_1 = require("../app/services/stripe/api/create-price");
+const configure_customer_portal_1 = require("../app/services/stripe/api/configure-customer-portal");
+const prisma = new client_1.PrismaClient();
+async function seed() {
+ const plans = await (0, get_plan_1.getAllPlans)();
+ if (plans.length > 0) {
+ console.log('๐ Plans has already been seeded.');
+ return true;
+ }
+ const seedProducts = Object.values(plans_1.PRICING_PLANS).map(async ({ id, name, description, features, limits, prices }) => {
+ // Format prices to match Stripe's API.
+ const pricesByInterval = Object.entries(prices).flatMap(([interval, price]) => {
+ return Object.entries(price).map(([currency, amount]) => ({
+ interval,
+ currency,
+ amount,
+ }));
+ });
+ // Create Stripe product.
+ await (0, create_product_1.createStripeProduct)({
+ id,
+ name,
+ description: description || undefined,
+ });
+ // Create Stripe price for the current product.
+ const stripePrices = (await Promise.all(pricesByInterval.map((price) => {
+ return (0, create_price_1.createStripePrice)(id, price);
+ })));
+ // Store product into database.
+ await db_1.db.plan.create({
+ data: {
+ id,
+ name,
+ description,
+ limits: {
+ create: {
+ maxItems: limits.maxItems,
+ },
+ },
+ prices: {
+ create: stripePrices.map((price) => ({
+ id: price.id,
+ amount: price.unit_amount,
+ currency: price.currency,
+ interval: price.recurring.interval,
+ })),
+ },
+ },
+ });
+ // Return product ID and prices.
+ // Used to configure the Customer Portal.
+ return {
+ product: id,
+ prices: stripePrices.map((price) => price.id),
+ };
+ });
+ // Create Stripe products and stores them into database.
+ const seededProducts = await Promise.all(seedProducts);
+ console.log(`๐ฆ Stripe Products has been successfully created.`);
+ // Configure Customer Portal.
+ await (0, configure_customer_portal_1.configureStripeCustomerPortal)(seededProducts);
+ console.log(`๐ Stripe Customer Portal has been successfully configured.`);
+ console.log('๐ Visit: https://dashboard.stripe.com/test/products to see your products.');
+}
+seed()
+ .catch((err) => {
+ console.error(err);
+ process.exit(1);
+})
+ .finally(async () => {
+ await prisma.$disconnect();
+});
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..8830cf6821b354114848e6354889b8ecf6d2bc61
GIT binary patch
literal 16958
zcmeI3+jCXb9mnJN2h^uNlXH@jlam{_a8F3W{T}Wih>9YJpaf7TUbu)A5fv|h7OMfR
zR;q$lr&D!wv|c)`wcw1?>4QT1(&|jdsrI2h`Rn)dTW5t$8pz=s3_5L?#oBxAowe8R
z_WfPfN?F+@`q$D@rvC?(W!uWieppskmQ~YG*>*L?{img@tWpnYXZslxeh#TSUS3{q
z1Ju6JcfQSbQuORq69@YK(X-3c9vC2c2a2z~zw=F=50@pm0PUiCAm!bAT?2jpM`(^b
zC|2&Ngngt^<>oCv#?P(AZ`5_84x#QBPulix)TpkIAUp=(KgGo4CVS~Sxt
zVoR4>r5g9%bDh7hi0|v$={zr>CHd`?-l4^Ld(Z9PNz9piFY+llUw_x4ou7Vf-q%$g
z)&)J4>6Ft~RZ(uV>dJD|`nxI1^x{X@Z5S<=vf;V3w_(*O-7}W<=e$=}CB9_R;)m9)d7`d_xx+nl^Bg|%ew=?uoKO8w
zeQU7h;~8s!@9-k>7Cx}1SDQ7m(&miH
zs8!l*wOJ!GHbdh)pD--&W3+w`9YJ=;m^FtMY=`mTq8pyV!-@L6smwp3(q?G>=_4v^
zn(ikLue7!y70#2uhqUVpb7fp!=xu2{aM^1P^pts#+feZv8d~)2sf`sjXLQCEj;pdI
z%~f`JOO;*KnziMv^i_6+?mL?^wrE_&=IT9o1i!}Sd4Sx4O@w~1bi1)8^1W_hyQJ
z^TuRsvF7h=(){B?>(sXvYR-1?7~Zr<=SJ1Cw!i~yfi=4h6o3O~(-Sb2Ilwq%g$+V`
z>(C&N1!FV5rWF&iwt8~b)=jIn4b!XbrWrZgIHTISrdHcpjjx=TwJXI7_%Ks4oFLl9
zNT;!%!P4~xH85njXdfqgnIxIFOOKW`W$fxU%{{5wZkVF^G=JB$oUNU5dQSL&ZnR1s
z*ckJ$R`eCUJsWL>j6*+|2S1TL_J|Fl&kt=~XZF=+=iT0Xq1*KU-NuH%NAQff$LJp3
zU_*a;@7I0K{mqwux87~vwsp<}@P>KNDb}3U+6$rcZ114|QTMUSk+rhPA(b{$>pQTc
zIQri{+U>GMzsCy0Mo4BfWXJlkk;RhfpWpAB{=Rtr*d1MNC+H3Oi5+3D$gUI&AjV-1
z=0ZOox+bGyHe=yk-yu%=+{~&46C$ut^ZN+ysx$NH}*F43)3bKkMsxGyIl#>7Yb8W
zO{}&LUO8Ow{7>!bvSq?X{15&Y|4}0w2=o_^0ZzYgB+4HhZ4>s*mW&?RQ6&AY|CPcx
z$*LjftNS|H)ePYnIKNg{ck*|y7EJ&Co0ho0K`!{ENPkASeKy-JWE}dF_%}j)Z5a&q
zXAI2gPu6`s-@baW=*+keiE$ALIs5G6_X_6kgKK8n3jH2-H9`6bo)Qn1
zZ2x)xPt1=`9V|bE4*;j9$X20+xQCc$rEK|9OwH-O+Q*k`ZNw}K##SkY
z3u}aCV%V|j@!gL5(*5fuWo>JFjeU9Qqk`$bdwH8(qZovE2tA7WUpoCE=VKm^eZ|vZ
z(k<+j*mGJVah>8CkAsMD6#I$RtF;#57Wi`c_^k5?+KCmX$;Ky2*6|Q^bJ8+s%2MB}OH-g$Ev^
zO3uqfGjuN%CZiu<`aCuKCh{kK!dDZ+CcwgIeU2dsDfz+V>V3BDb~)~
zO!2l!_)m;ZepR~sL+-~sHS7;5ZB|~uUM&&5vDda2b
z)CW8S6GI*oF><|ZeY5D^+Mcsri)!tmrM33qvwI4r9o@(GlW!u2R>>sB|E#%W`c*@5
z|0iA|`{6aA7D4Q?vc1{vT-#yytn07`H!QIO^1+X7?zG3%y0gPdIPUJ#s*DNAwd}m1_IMN1^T&be~+E
z_z%1W^9~dl|Me9U6+3oNyuMDkF*z_;dOG(Baa*yq;TRiw{EO~O_S6>e*L(+Cdu(TM
z@o%xTCV%hi&p)x3_inIF!b|W4|AF5p?y1j)cr9RG@v%QVaN8&LaorC-kJz_ExfVHB
za!mtuee#Vb?dh&bwrfGHYAiX&&|v$}U*UBM;#F!N=x>x|G5s0zOa9{(`=k4v^6iK3
z8d&=O@xhDs{;v7JQ%eO;!Bt`&*MH&d
zp^K#dkq;jnJz%%bsqwlaKA5?fy
zS5JDbO#BgSAdi8NM
zDo2SifX6^Z;vn>cBh-?~r_n9qYvP|3ihrnqq6deS-#>l#dV4mX|G%L8|EL;$U+w69
z;rTK3FW$ewUfH|R-Z;3;jvpfiDm?Fvyu9PeR>wi|E8>&j2Z@2h`U}|$>2d`BPV3pz#ViIzH8v6pP^L-p!GbLv<;(p>}_6u&E6XO5-
zJ8JEvJ1)0>{iSd|kOQn#?0rTYL=KSmgMHCf$Qbm;7|8d(goD&T-~oCDuZf57iP#_Y
zmxaoOSjQsm*^u+m$L9AMqwi=6bpdiAY6k3akjGN{xOZ`_J<~Puyzpi7yhhKrLmXV;
z@ftONPy;Uw1F#{_fyGbk04yLE01v=i_5`RqQP+SUH0nb=O?l!J)qCSTdsbmjFJrTm
zx4^ef@qt{B+TV_OHOhtR?XT}1Etm(f21;#qyyW6FpnM+S7*M1iME?9fe8d-`Q#InN
z?^y{C_|8bxgUE@!o+Z72C)BrS&5D`gb-X8kq*1G7Uld-z19V}HY~mK#!o9MC-*#^+
znEsdc-|jj0+%cgBMy(cEkq4IQ1D*b;17Lyp>Utnsz%LRTfjQKL*vo(yJxwtw^)l|!
z7jhIDdtLB}mpkOIG&4@F+9cYkS5r%%jz}I0R#F4oBMf-|Jmmk*
zk^OEzF%}%5{a~kGYbFjV1n>HKC+a`;&-n*v_kD2DPP~n5(QE3C;30L<32GB*qV2z$
zWR1Kh=^1-q)P37WS6YWKlUSDe=eD^u_CV+P)q!3^{=$#b^auGS7m8zFfFS<>(e~)TG
z&uwWhSoetoe!1^%)O}=6{SUcw-UQmw+i8lokRASPsbT=H|4D|(
zk^P7>TUEFho!3qXSWn$m2{lHXw
zD>eN6-;wwq9(?@f^F4L2Ny5_6!d~iiA^s~(|B*lbZir-$&%)l>%Q(36yOIAu|326K
ztmBWz|MLA{Kj(H_{w2gd*nZ6a@ma(w==~EHIscEk|C=NGJa%Ruh4_+~f|%rt{I5v*
zIX@F?|KJID56-ivb+PLo(9hn_CdK{irOcL15>JNQFY112^$+}JPyI{uQ~$&E*=ri;
z`d^fH?4f=8vKHT4!p9O*fX(brB75Y9?e>T9=X#Fc@V#%@5^)~#zu5I(=>LQA-EGTS
zecy*#6gG+8lapch#Hh%vl(+}J;Q!hC1OKoo;#h3#V%5Js)tQ)|>pTT@1ojd+F9Gey
zg`B)zm`|Mo%tH31s4=<+`Pu|B3orXwNyIcNN>;fBkIj^X8P}RXhF=
zXQK1u5RLN7k#_Q(KznJrALtMM13!vhfr025ar?@-%{l|uWt@NEd<$~n>RQL{
z+o;->n)+~0tt(u|o_9h!T`%M8%)w2awpV9b*xz9Pl-daUJm3y-HT%xg`^mFd6LBeL
z!0~s;zEr)Bn9x)I(wx`;JVwvRcc^io2XX(Nn3vr3dgbrr@YJ?K3w18P*52^ieBCQP
z=Up1V$N2~5ppJHRTeY8QfM(7Yv&RG7oWJAyv?c3g(29)P)u;_o&w|&)HGDIinXT~p
z3;S|e$=&Tek9Wn!`cdY+d-w@o`37}x{(hl>ykB|%9yB$CGdIcl7Z?d&lJ%}QHck77
zJPR%C+s2w1_Dl_pxu6$Zi!`HmoD-%7OD@7%lKLL^Ixd9VlRSW*o&$^iQ2z+}hTgH)
z#91TO#+jH<`w4L}XWOt(`gqM*uTUcky`O(mEyU|4dJoy6*UZJ7%*}ajuos%~>&P2j
zk23f5<@GeV?(?`l=ih+D8t`d72xrUjv0wsg;%s1@*2p?TQ;n2$pV7h?_T%sL>iL@w
zZ{lmc<|B7!e&o!zs6RW+u8+aDyUdG>ZS(v&rT$QVymB7sEC@VsK1dg^3F@K90-wYB
zX!we79q(Wh~izEdPF0&->x`(6LA>F$~{{xE8-3Wzyfe`+Lsce(?uj{k@lb97YTJt#>l*Z&LyKX@zjmu?UJC9w~;|NsB{%7G}y*uNDBxirfC
EKbET!0{{R3
literal 0
HcmV?d00001
diff --git a/remix.config.js b/remix.config.js
new file mode 100644
index 00000000..1b06bc9a
--- /dev/null
+++ b/remix.config.js
@@ -0,0 +1,25 @@
+/**
+ * @type {import('@remix-run/dev').AppConfig}
+ */
+const { flatRoutes } = require('remix-flat-routes')
+
+module.exports = {
+ postcss: true,
+ tailwind: true,
+ serverModuleFormat: 'cjs',
+ ignoredRouteFiles: ['**/.*'],
+
+ // Future Features.
+ future: {
+ v2_meta: false,
+ v2_errorBoundary: true,
+ v2_routeConvention: true,
+ v2_normalizeFormMethod: true,
+ unstable_dev: true,
+ },
+
+ // Flat Routes.
+ routes: async (defineRoutes) => {
+ return flatRoutes('routes', defineRoutes)
+ },
+}
diff --git a/remix.env.d.ts b/remix.env.d.ts
new file mode 100644
index 00000000..dcf8c45e
--- /dev/null
+++ b/remix.env.d.ts
@@ -0,0 +1,2 @@
+///
+///
diff --git a/remix.init/.gitignore b/remix.init/.gitignore
new file mode 100644
index 00000000..a23b6aaa
--- /dev/null
+++ b/remix.init/.gitignore
@@ -0,0 +1,7 @@
+# Required file for `remix.init`.
+# We do not want to push our packages into github repository.
+node_modules
+
+/build
+/public/build
+.env
diff --git a/remix.init/index.js b/remix.init/index.js
new file mode 100644
index 00000000..f5bdb756
--- /dev/null
+++ b/remix.init/index.js
@@ -0,0 +1,347 @@
+/**
+ * Remix Init.
+ * @author https://github.com/dev-xo
+ */
+const { execSync } = require('child_process')
+const fs = require('fs/promises')
+const path = require('path')
+const rimraf = require('rimraf')
+const inquirer = require('inquirer')
+const crypto = require('crypto')
+
+const toml = require('@iarna/toml')
+const YAML = require('yaml')
+const semver = require('semver')
+const PackageJson = require('@npmcli/package-json')
+
+/**
+ * Constants.
+ */
+const DEFAULT_DB = 'SQLite'
+const SQLITE_DB = 'SQLite'
+const POSTGRESQL_DB = 'PostgreSQL'
+const DEFAULT_PROJECT_NAME_MATCHER = /stripe-stack-dev/gim
+
+/**
+ * Helpers.
+ */
+function escapeRegExp(string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
+function getRandomString(length) {
+ return crypto.randomBytes(length).toString('hex')
+}
+
+/**
+ * Returns the version of the package manager used in the workspace.
+ */
+function getPackageManagerVersion(packageManager) {
+ return execSync(`${packageManager} --version`).toString('utf-8').trim()
+}
+
+/**
+ * Returns commands for the package manager used in workspace.
+ */
+function getPackageManagerCommand(packageManager) {
+ return {
+ npm: () => ({
+ exec: 'npx',
+ lockfile: 'package-lock.json',
+ run: (script, args) => `npm run ${script} ${args ? `-- ${args}` : ''}`,
+ }),
+ pnpm: () => {
+ const pnpmVersion = getPackageManagerVersion('pnpm')
+ const includeDoubleDashBeforeArgs = semver.lt(pnpmVersion, '7.0.0')
+ const useExec = semver.gte(pnpmVersion, '6.13.0')
+
+ return {
+ exec: useExec ? 'pnpm exec' : 'pnpx',
+ lockfile: 'pnpm-lock.yaml',
+ run: (script, args) =>
+ includeDoubleDashBeforeArgs
+ ? `pnpm run ${script} ${args ? `-- ${args}` : ''}`
+ : `pnpm run ${script} ${args || ''}`,
+ }
+ },
+ yarn: () => ({
+ exec: 'yarn',
+ lockfile: 'yarn.lock',
+ run: (script, args) => `yarn ${script} ${args || ''}`,
+ }),
+ }[packageManager]()
+}
+
+/**
+ * Filters out unused dependencies.
+ */
+function removeUnusedDependencies(dependencies, unusedDependencies) {
+ return Object.fromEntries(
+ Object.entries(dependencies).filter(([key]) => !unusedDependencies.includes(key)),
+ )
+}
+
+/**
+ * Cleans up Typescript references from GitHub workflows.
+ */
+async function cleanupTypescriptWorkflow(rootDirectory) {
+ const DEPLOY_WORKFLOW_PATH = path.join(
+ rootDirectory,
+ '.github',
+ 'workflows',
+ 'deploy.yml',
+ )
+
+ const deployWorkflow = await fs.readFile(DEPLOY_WORKFLOW_PATH, 'utf-8')
+ const parsedWorkflow = YAML.parse(deployWorkflow)
+
+ delete parsedWorkflow.jobs.typecheck
+ parsedWorkflow.jobs.deploy.needs = parsedWorkflow.jobs.deploy.needs.filter(
+ (need) => need !== 'typecheck',
+ )
+
+ await fs.writeFile(DEPLOY_WORKFLOW_PATH, YAML.stringify(parsedWorkflow))
+}
+
+/**
+ * Updates package.json.
+ */
+async function updatePackageJson(rootDirectory, isTypeScript, APP_NAME) {
+ const packageJson = await PackageJson.load(rootDirectory)
+
+ const {
+ devDependencies,
+ prisma: { seed: prismaSeed, ...prisma },
+ scripts: { typecheck, validate, ...scripts },
+ } = packageJson.content
+
+ packageJson.update({
+ name: APP_NAME,
+ devDependencies: isTypeScript
+ ? devDependencies
+ : removeUnusedDependencies(devDependencies, ['ts-node']),
+ prisma: isTypeScript
+ ? { seed: prismaSeed, ...prisma }
+ : {
+ seed: prismaSeed.replace('ts-node', 'node').replace('seed.ts', 'seed.js'),
+ ...prisma,
+ },
+ scripts: isTypeScript
+ ? { ...scripts, typecheck, validate }
+ : { ...scripts, validate: validate.replace(' typecheck', '') },
+ })
+
+ await packageJson.save()
+}
+
+/**
+ * Creates a new `.env` file, based on `.env.example`.
+ * Also removes `.env.example`.
+ */
+async function initEnvFile(rootDirectory) {
+ const NEW_ENV_PATH = path.join(rootDirectory, '.env')
+ const EXAMPLE_ENV_PATH = path.join(rootDirectory, '.env.example')
+
+ const exampleEnv = await fs.readFile(EXAMPLE_ENV_PATH, 'utf-8')
+ const newEnv = exampleEnv.replace(
+ /^SESSION_SECRET=.*$/m,
+ `SESSION_SECRET="${getRandomString(16)}"`,
+ )
+ await fs.writeFile(NEW_ENV_PATH, newEnv)
+ await fs.unlink(EXAMPLE_ENV_PATH)
+}
+
+/**
+ * Replaces default project name for the one provided by `DIR_NAME`.
+ */
+async function updateProjectNameFromRiles(rootDirectory, APP_NAME) {
+ // Paths.
+ const FLY_TOML_PATH = path.join(rootDirectory, 'fly.toml')
+ const README_PATH = path.join(rootDirectory, 'README.md')
+
+ const [flyToml, readme] = await Promise.all([
+ fs.readFile(FLY_TOML_PATH, 'utf-8'),
+ fs.readFile(README_PATH, 'utf-8'),
+ ])
+
+ // Replaces Fly.toml file.
+ const newFlyToml = toml.parse(flyToml)
+ newFlyToml.app = newFlyToml.app.replace(DEFAULT_PROJECT_NAME_MATCHER, APP_NAME)
+
+ // Replaces README.md file.
+ const newReadme = readme.replace(DEFAULT_PROJECT_NAME_MATCHER, APP_NAME)
+
+ await Promise.all([
+ fs.writeFile(FLY_TOML_PATH, toml.stringify(newFlyToml)),
+ fs.writeFile(README_PATH, newReadme),
+ ])
+}
+
+/**
+ * Updates `Dockerfile` based on the package manager used in workspace.
+ */
+async function replaceDockerLockFile(rootDirectory, pm) {
+ const DOCKERFILE_PATH = path.join(rootDirectory, 'Dockerfile')
+
+ const dockerfile = await fs.readFile(DOCKERFILE_PATH, 'utf-8')
+ const newDockerfile = pm.lockfile
+ ? dockerfile.replace(
+ new RegExp(escapeRegExp('ADD package.json'), 'g'),
+ `ADD package.json ${pm.lockfile}`,
+ )
+ : dockerfile
+ await fs.writeFile(DOCKERFILE_PATH, newDockerfile)
+}
+
+/**
+ * Prepares environment for deployment at Fly.io.
+ */
+async function initDeployEnvironment(rootDirectory) {
+ // Prisma Paths.
+ const PRISMA_SCHEMA_PATH = path.join(rootDirectory, 'prisma', 'schema.prisma')
+ const PRISMA_MIGRATIONS_PATH = path.join(rootDirectory, 'prisma', 'migrations')
+ const PRISMA_DEV_DB_PATH = path.join(rootDirectory, 'prisma', 'dev.db')
+ const PRISMA_DEV_DB_JOURNAL_PATH = path.join(rootDirectory, 'prisma', 'dev.db-journal')
+
+ // Github Workflows Paths.
+ const DEPLOY_WORKFLOW_PATH = path.join(
+ rootDirectory,
+ '.github',
+ 'workflows',
+ 'deploy.yml',
+ )
+
+ // Matches & Replacers.
+ const PRISMA_SQLITE_MATCHER = 'sqlite'
+ const PRISMA_POSTGRES_REPLACER = 'postgresql'
+
+ // Cleaning prisma folder files for SQLite and Postgres.
+ rimraf.sync(PRISMA_MIGRATIONS_PATH, {}, () => true)
+ rimraf.sync(PRISMA_DEV_DB_PATH, {}, () => true)
+ rimraf.sync(PRISMA_DEV_DB_JOURNAL_PATH, {}, () => true)
+
+ // Inits Inquirer.
+ const dbChoice = await inquirer
+ .prompt([
+ {
+ type: 'list',
+ name: 'database',
+ message: 'What database will your project run on?',
+ default: DEFAULT_DB,
+ choices: [SQLITE_DB, POSTGRESQL_DB],
+ },
+ ])
+ .then(async (answers) => {
+ const dbAnswer = answers.database
+
+ if (dbAnswer === POSTGRESQL_DB) {
+ // Paths.
+ const POSTGRES_DEPLOY_WORKFLOW_PATH = path.join(
+ rootDirectory,
+ 'remix.init',
+ 'lib',
+ 'postgres',
+ 'deploy.yml',
+ )
+ const POSTGRES_DOCKERFILE_PATH = path.join(
+ rootDirectory,
+ 'remix.init',
+ 'lib',
+ 'postgres',
+ 'Dockerfile',
+ )
+ const POSTGRES_FLY_TOML_PATH = path.join(
+ rootDirectory,
+ 'remix.init',
+ 'lib',
+ 'postgres',
+ 'fly.toml',
+ )
+ const POSTGRES_DOCKER_COMPOSE_YML_PATH = path.join(
+ rootDirectory,
+ 'remix.init',
+ 'lib',
+ 'postgres',
+ 'docker-compose.yml',
+ )
+ const POSTGRES_ENV_EXAMPLE_PATH = path.join(
+ rootDirectory,
+ 'remix.init',
+ 'lib',
+ 'postgres',
+ '.env.example',
+ )
+
+ // Replaces Prisma files.
+ const prismaSchema = await fs.readFile(PRISMA_SCHEMA_PATH, 'utf-8')
+ const newPrismaSchema = prismaSchema.replace(
+ PRISMA_SQLITE_MATCHER,
+ PRISMA_POSTGRES_REPLACER,
+ )
+ await fs.writeFile(PRISMA_SCHEMA_PATH, newPrismaSchema)
+
+ // Replaces GitHub workflows.
+ await fs.unlink(DEPLOY_WORKFLOW_PATH)
+ await fs.rename(POSTGRES_DEPLOY_WORKFLOW_PATH, DEPLOY_WORKFLOW_PATH)
+
+ // Replaces deploy files.
+ await fs.rename(POSTGRES_DOCKERFILE_PATH, path.join(rootDirectory, 'Dockerfile'))
+ await fs.rename(POSTGRES_FLY_TOML_PATH, path.join(rootDirectory, 'fly.toml'))
+ await fs.rename(
+ POSTGRES_DOCKER_COMPOSE_YML_PATH,
+ path.join(rootDirectory, 'docker-compose.yml'),
+ )
+
+ // Replaces .env.example file.
+ await fs.rename(
+ POSTGRES_ENV_EXAMPLE_PATH,
+ path.join(rootDirectory, '.env.example'),
+ )
+ }
+
+ return dbAnswer
+ })
+ .catch((err) => {
+ console.log(err)
+ })
+
+ return dbChoice
+}
+
+/**
+ * Main function.
+ * Runs after the project has been generated.
+ */
+async function main({ rootDirectory, packageManager, isTypeScript }) {
+ const DIR_NAME = path.basename(rootDirectory)
+ const APP_NAME = DIR_NAME.replace(/[^a-zA-Z0-9-_]/g, '-')
+
+ // Returns commands for the package manager used in workspace.
+ const pm = getPackageManagerCommand(packageManager)
+
+ // Cleans up all Typescript references from the project.
+ if (!isTypeScript) await Promise.all([cleanupTypescriptWorkflow(rootDirectory)])
+
+ // Prepares environment for deployment at Fly.io.
+ await initDeployEnvironment(rootDirectory)
+
+ await Promise.all([
+ // Updates package.json.
+ updatePackageJson(rootDirectory, isTypeScript, APP_NAME),
+
+ // Creates a new `.env` file, based on `.env.example`.
+ initEnvFile(rootDirectory),
+
+ // Replaces default project name for the one provided by `DIR_NAME`.
+ updateProjectNameFromRiles(rootDirectory, APP_NAME),
+
+ // Updates `Dockerfile` based on the package manager used in workspace.
+ replaceDockerLockFile(rootDirectory, pm),
+ ])
+
+ console.log('๐ Template has been successfully initialized.')
+ console.log('๐ Check documentation to successfully initialize your database.')
+ console.log('')
+ console.log(`๐ Start development server with \`${pm.run('dev')}\``.trim())
+}
+
+module.exports = main
diff --git a/remix.init/lib/postgres/.env.example b/remix.init/lib/postgres/.env.example
new file mode 100644
index 00000000..20877d10
--- /dev/null
+++ b/remix.init/lib/postgres/.env.example
@@ -0,0 +1,27 @@
+# Base.
+NODE_ENV="development"
+SESSION_SECRET="SESSION_SECRET"
+ENCRYPTION_SECRET="ENCRYPTION_SECRET"
+
+# Host.
+DEV_HOST_URL="http://localhost:3000"
+PROD_HOST_URL="https://example.com"
+
+# Environment variables declared in this file are automatically made available to Prisma.
+# More details: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
+DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE"
+
+# [Optional] Social Authentification.
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+
+# [Optional] Email Provider.
+EMAIL_PROVIDER_API_KEY=
+
+# Stripe.
+STRIPE_PUBLIC_KEY=
+STRIPE_SECRET_KEY=
+
+# Stripe Webhook.
+DEV_STRIPE_WEBHOOK_ENDPOINT=""
+PROD_STRIPE_WEBHOOK_ENDPOINT=""
\ No newline at end of file
diff --git a/remix.init/lib/postgres/Dockerfile b/remix.init/lib/postgres/Dockerfile
new file mode 100644
index 00000000..19cd5b3b
--- /dev/null
+++ b/remix.init/lib/postgres/Dockerfile
@@ -0,0 +1,53 @@
+# Base node image.
+FROM node:16-bullseye-slim as base
+
+# Set global environment variables.
+ENV NODE_ENV=production
+
+# Install openssl for Prisma.
+RUN apt-get update && apt-get install -y openssl
+
+# Install all node_modules, including dev dependencies.
+FROM base as deps
+
+WORKDIR /myapp
+
+ADD package.json ./
+RUN npm install --production=false
+
+# Setup production node_modules.
+FROM base as production-deps
+
+WORKDIR /myapp
+COPY --from=deps /myapp/node_modules /myapp/node_modules
+
+ADD package.json ./
+RUN npm prune --production
+
+# Build the app.
+FROM base as build
+
+WORKDIR /myapp
+COPY --from=deps /myapp/node_modules /myapp/node_modules
+
+ADD prisma .
+RUN npx prisma generate
+
+ADD . .
+RUN npm run build
+
+# Finally, build the production image with minimal footprint.
+FROM base
+
+WORKDIR /myapp
+
+COPY --from=production-deps /myapp/node_modules /myapp/node_modules
+COPY --from=build /myapp/node_modules/.prisma /myapp/node_modules/.prisma
+
+COPY --from=build /myapp/build /myapp/build
+COPY --from=build /myapp/public /myapp/public
+COPY --from=build /myapp/prisma /myapp/prisma
+COPY --from=build /myapp/package.json /myapp/package.json
+COPY --from=build /myapp/start.sh /myapp/start.sh
+
+ENTRYPOINT [ "./start.sh" ]
\ No newline at end of file
diff --git a/remix.init/lib/postgres/deploy.yml b/remix.init/lib/postgres/deploy.yml
new file mode 100644
index 00000000..f9b9f9f1
--- /dev/null
+++ b/remix.init/lib/postgres/deploy.yml
@@ -0,0 +1,186 @@
+name: ๐ Deploy
+on:
+ push:
+ branches:
+ - main
+ - dev
+ pull_request: {}
+
+permissions:
+ actions: write
+ contents: read
+
+jobs:
+ lint:
+ name: โฌฃ ESLint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Setup Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+
+ - name: Install Dependencies
+ uses: bahmutov/npm-install@v1
+ with:
+ useLockFile: false
+
+ - name: Run Lint
+ run: npm run lint
+
+ typecheck:
+ name: สฆ TypeScript
+ runs-on: ubuntu-latest
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Setup Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+
+ - name: Install Dependencies
+ uses: bahmutov/npm-install@v1
+ with:
+ useLockFile: false
+
+ - name: Run Typechecking
+ run: npm run typecheck --if-present
+
+ playwright:
+ name: ๐ญ Playwright
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Copy Environment Variables
+ run: cp .env.example .env
+
+ - name: Setup Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: 16
+
+ - name: Install Dependencies
+ uses: bahmutov/npm-install@v1
+ with:
+ useLockFile: false
+
+ - name: Install Playwright Browsers
+ run: npx playwright install --with-deps
+
+ - name: Setup Database
+ run: npx prisma migrate reset --force --skip-seed
+
+ - name: Build
+ run: npm run build
+
+ - name: Run Playwright Tests
+ run: npx playwright test
+
+ - name: Upload Report
+ uses: actions/upload-artifact@v3
+ if: always()
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 30
+
+ build:
+ name: ๐ณ Build
+ # Only build / deploy main branch on pushes.
+ if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Read App Name
+ uses: SebRollen/toml-action@v1.0.2
+ id: app_name
+ with:
+ file: 'fly.toml'
+ field: 'app'
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v2
+ with:
+ version: v0.9.1
+
+ - name: Cache Docker Layers
+ uses: actions/cache@v3
+ with:
+ path: /tmp/.buildx-cache
+ key: ${{ runner.os }}-buildx-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-buildx-
+
+ - name: Fly Registry Auth
+ uses: docker/login-action@v2
+ with:
+ registry: registry.fly.io
+ username: x
+ password: ${{ secrets.FLY_API_TOKEN }}
+
+ - name: Docker build
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ push: true
+ tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}
+ build-args: |
+ COMMIT_SHA=${{ github.sha }}
+ cache-from: type=local,src=/tmp/.buildx-cache
+ cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+
+ - name: Move Cache
+ run: |
+ rm -rf /tmp/.buildx-cache
+ mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+
+ deploy:
+ name: ๐ Deploy
+ runs-on: ubuntu-latest
+ needs: [lint, typecheck, playwright, build]
+ # Only build / deploy main branch on pushes.
+ if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }}
+
+ steps:
+ - name: Cancel Previous Runs
+ uses: styfle/cancel-workflow-action@0.11.0
+
+ - name: Checkout Repository
+ uses: actions/checkout@v3
+
+ - name: Read App Name
+ uses: SebRollen/toml-action@v1.0.2
+ id: app_name
+ with:
+ file: 'fly.toml'
+ field: 'app'
+
+ - name: Deploy Production
+ if: ${{ github.ref == 'refs/heads/main' }}
+ uses: superfly/flyctl-actions@1.3
+ with:
+ args: 'deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}'
+ env:
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/remix.init/lib/postgres/docker-compose.yml b/remix.init/lib/postgres/docker-compose.yml
new file mode 100644
index 00000000..0a45da4a
--- /dev/null
+++ b/remix.init/lib/postgres/docker-compose.yml
@@ -0,0 +1,13 @@
+version: '3.7'
+services:
+ postgres:
+ image: postgres:latest
+ restart: always
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=postgres
+ - POSTGRES_DB=postgres
+ ports:
+ - '5432:5432'
+ volumes:
+ - ./postgres-data:/var/lib/postgresql/data
diff --git a/remix.init/lib/postgres/fly.toml b/remix.init/lib/postgres/fly.toml
new file mode 100644
index 00000000..5e293ca0
--- /dev/null
+++ b/remix.init/lib/postgres/fly.toml
@@ -0,0 +1,52 @@
+app = "stripe-stack"
+
+kill_signal = "SIGINT"
+kill_timeout = 5
+processes = []
+
+[experimental]
+ allowed_public_ports = []
+ auto_rollback = true
+ cmd = "start.sh"
+ entrypoint = "sh"
+
+[env]
+ PORT = "8080"
+
+[[services]]
+ internal_port = 8080
+ processes = ["app"]
+ protocol = "tcp"
+ script_checks = []
+
+ [services.concurrency]
+ hard_limit = 25
+ soft_limit = 20
+ type = "connections"
+
+ [[services.ports]]
+ force_https = true
+ handlers = ["http"]
+ port = 80
+
+
+ [[services.ports]]
+ handlers = ["tls", "http"]
+ port = 443
+
+ [[services.tcp_checks]]
+ grace_period = "1s"
+ interval = "15s"
+ restart_limit = 0
+ timeout = "2s"
+
+ [[services.http_checks]]
+ grace_period = "5s"
+ interval = "30s"
+ method = "get"
+ path = "/api/healthcheck"
+ protocol = "http"
+ timeout = "2s"
+ tls_skip_verify = false
+ [services.http_checks.headers]
+
diff --git a/remix.init/package.json b/remix.init/package.json
new file mode 100644
index 00000000..a2e279d3
--- /dev/null
+++ b/remix.init/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "remix-init",
+ "author": "https://github.com/dev-xo",
+ "private": true,
+ "main": "index.js",
+ "license": "MIT",
+ "dependencies": {
+ "rimraf": "^3.0.2",
+ "@iarna/toml": "^2.2.5",
+ "yaml": "^2.1.1",
+ "semver": "^7.3.7",
+ "@npmcli/package-json": "^2.0.0"
+ }
+}
diff --git a/start.sh b/start.sh
new file mode 100755
index 00000000..c914f2b8
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,8 @@
+# This file is how Fly starts the server (configured in fly.toml).
+# Before starting the server, we need to run any prisma migrations that haven't yet been run.
+# Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
+
+set -ex
+npx prisma migrate deploy
+npx node prisma/seed/prisma/seed.js
+npm run start
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 00000000..9ef5b450
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,10 @@
+/**
+ * @type {import('tailwindcss').Config}
+ */
+module.exports = {
+ content: ['./app/**/*.{js,ts,jsx,tsx}'],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+}
diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts
new file mode 100644
index 00000000..adba96aa
--- /dev/null
+++ b/tests/e2e/app.spec.ts
@@ -0,0 +1,8 @@
+import { test, expect } from '@playwright/test'
+
+test.describe('App', () => {
+ test('Should navigate to /.', async ({ page }) => {
+ await page.goto('/')
+ await expect(page).toHaveURL('/')
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..760ae332
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2019"],
+ "target": "ES2019",
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "strict": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "allowJs": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ }
+ }
+}
diff --git a/tsconfig.seed.json b/tsconfig.seed.json
new file mode 100644
index 00000000..1f07e73d
--- /dev/null
+++ b/tsconfig.seed.json
@@ -0,0 +1,18 @@
+{
+ "include": ["./prisma/seed.ts"],
+ "compilerOptions": {
+ "lib": ["ES2019"],
+ "target": "ES2019",
+ "moduleResolution": "node",
+ "module": "CommonJS",
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "experimentalDecorators": true,
+ "outDir": "./prisma/seed",
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./app/*"]
+ }
+ }
+}