├── nhost
├── metadata
│ ├── actions.graphql
│ ├── allow_list.yaml
│ ├── api_limits.yaml
│ ├── network.yaml
│ ├── cron_triggers.yaml
│ ├── inherited_roles.yaml
│ ├── rest_endpoints.yaml
│ ├── version.yaml
│ ├── query_collections.yaml
│ ├── graphql_schema_introspection.yaml
│ ├── databases
│ │ ├── default
│ │ │ └── tables
│ │ │ │ ├── public_customers.yaml
│ │ │ │ ├── tables.yaml
│ │ │ │ ├── auth_provider_requests.yaml
│ │ │ │ ├── public_profile.yaml
│ │ │ │ ├── auth_providers.yaml
│ │ │ │ ├── public_plans.yaml
│ │ │ │ ├── public_products.yaml
│ │ │ │ ├── auth_roles.yaml
│ │ │ │ ├── auth_user_roles.yaml
│ │ │ │ ├── auth_refresh_tokens.yaml
│ │ │ │ ├── auth_user_security_keys.yaml
│ │ │ │ ├── public_profiles.yaml
│ │ │ │ ├── storage_files.yaml
│ │ │ │ ├── auth_user_providers.yaml
│ │ │ │ ├── storage_buckets.yaml
│ │ │ │ └── auth_users.yaml
│ │ └── databases.yaml
│ ├── actions.yaml
│ └── remote_schemas.yaml
├── emails
│ ├── en
│ │ ├── email-verify
│ │ │ ├── subject.txt
│ │ │ └── body.html
│ │ ├── password-reset
│ │ │ ├── subject.txt
│ │ │ └── body.html
│ │ ├── signin-passwordless
│ │ │ ├── subject.txt
│ │ │ └── body.html
│ │ ├── email-confirm-change
│ │ │ ├── subject.txt
│ │ │ └── body.html
│ │ └── signin-passwordless-sms
│ │ │ └── body.txt
│ └── fr
│ │ ├── email-verify
│ │ ├── subject.txt
│ │ └── body.html
│ │ ├── signin-passwordless-sms
│ │ └── body.txt
│ │ ├── signin-passwordless
│ │ ├── subject.txt
│ │ └── body.html
│ │ ├── email-confirm-change
│ │ ├── subject.txt
│ │ └── body.html
│ │ └── password-reset
│ │ ├── subject.txt
│ │ └── body.html
├── migrations
│ └── default
│ │ ├── 1669308895353_create_table_public_profile
│ │ ├── down.sql
│ │ └── up.sql
│ │ ├── 1669316801508_create_table_public_products
│ │ ├── down.sql
│ │ └── up.sql
│ │ ├── 1669976516108_rename_table_public_products
│ │ ├── down.sql
│ │ └── up.sql
│ │ ├── 1669325785961_run_sql_migration
│ │ ├── up.sql
│ │ └── down.sql
│ │ ├── 1669975961205_alter_table_public_profiles_add_column_plan_id
│ │ ├── down.sql
│ │ └── up.sql
│ │ ├── 1669975970500_alter_table_public_products_alter_column_price_id
│ │ ├── down.sql
│ │ └── up.sql
│ │ └── 1669317002587_alter_table_public_products_alter_column_price_id
│ │ ├── down.sql
│ │ └── up.sql
├── seeds
│ └── default
│ │ └── 001-plans.sql
└── config.yaml
├── components
├── ui
│ ├── Input
│ │ ├── index.ts
│ │ ├── Input.module.css
│ │ └── Input.tsx
│ ├── Navbar
│ │ ├── index.ts
│ │ ├── Navbar.module.css
│ │ └── Navbar.tsx
│ ├── Button
│ │ ├── index.ts
│ │ ├── Button.module.css
│ │ └── Button.tsx
│ ├── Footer
│ │ ├── index.ts
│ │ └── Footer.tsx
│ └── LoadingDots
│ │ ├── index.ts
│ │ ├── LoadingDots.tsx
│ │ └── LoadingDots.module.css
├── icons
│ ├── Logo.tsx
│ └── GitHub.tsx
├── Layout.tsx
└── Pricing.tsx
├── public
├── demo.png
├── og.png
├── favicon.ico
├── stripe.svg
├── vercel.svg
├── github.svg
├── nextjs.svg
└── nhost.svg
├── postcss.config.js
├── utils
├── react-query-client.ts
├── nhost.ts
├── helpers.ts
└── graphql-fetcher.ts
├── functions
├── _utils
│ ├── graphql
│ │ ├── plans.graphql
│ │ ├── users.graphql
│ │ └── profiles.graphql
│ ├── stripe.ts
│ ├── graphql-client.ts
│ └── helpers.ts
├── test.js
├── graphql
│ └── stripe.ts
├── events
│ └── users
│ │ └── insert
│ │ └── stripe.ts
├── webhooks
│ └── stripe.ts
└── custom
│ └── create-checkout-session.ts
├── tailwind.config.js
├── .env.development.example
├── next-env.d.ts
├── graphql
├── stripe.graphql
├── plans.graphql
└── user.graphql
├── styles
├── chrome-bug.css
└── main.css
├── pages
├── _document.tsx
├── index.tsx
├── _app.tsx
├── signin.tsx
└── account.tsx
├── .gitignore
├── graphql.config.yaml
├── tsconfig.json
├── LICENSE
├── package.json
├── types.ts
└── README.md
/nhost/metadata/actions.graphql:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/nhost/metadata/allow_list.yaml:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/nhost/metadata/api_limits.yaml:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/nhost/metadata/network.yaml:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/nhost/metadata/cron_triggers.yaml:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/nhost/metadata/inherited_roles.yaml:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/nhost/metadata/rest_endpoints.yaml:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/nhost/metadata/version.yaml:
--------------------------------------------------------------------------------
1 | version: 3
2 |
--------------------------------------------------------------------------------
/nhost/metadata/query_collections.yaml:
--------------------------------------------------------------------------------
1 | []
2 |
--------------------------------------------------------------------------------
/nhost/emails/en/email-verify/subject.txt:
--------------------------------------------------------------------------------
1 | Verify your email
--------------------------------------------------------------------------------
/nhost/emails/en/password-reset/subject.txt:
--------------------------------------------------------------------------------
1 | Reset your password
--------------------------------------------------------------------------------
/components/ui/Input/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Input';
2 |
--------------------------------------------------------------------------------
/components/ui/Navbar/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Navbar'
2 |
--------------------------------------------------------------------------------
/nhost/emails/en/signin-passwordless/subject.txt:
--------------------------------------------------------------------------------
1 | Secure sign-in link
--------------------------------------------------------------------------------
/nhost/emails/fr/email-verify/subject.txt:
--------------------------------------------------------------------------------
1 | Vérifier votre courriel
2 |
--------------------------------------------------------------------------------
/components/ui/Button/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Button';
2 |
--------------------------------------------------------------------------------
/components/ui/Footer/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Footer';
2 |
--------------------------------------------------------------------------------
/nhost/emails/en/email-confirm-change/subject.txt:
--------------------------------------------------------------------------------
1 | Change your email address
--------------------------------------------------------------------------------
/nhost/emails/en/signin-passwordless-sms/body.txt:
--------------------------------------------------------------------------------
1 | Your code is ${code}.
--------------------------------------------------------------------------------
/nhost/emails/fr/signin-passwordless-sms/body.txt:
--------------------------------------------------------------------------------
1 | Votre code est ${code}.
--------------------------------------------------------------------------------
/nhost/emails/fr/signin-passwordless/subject.txt:
--------------------------------------------------------------------------------
1 | Lien de connexion sécurisé
2 |
--------------------------------------------------------------------------------
/nhost/metadata/graphql_schema_introspection.yaml:
--------------------------------------------------------------------------------
1 | disabled_for_roles: []
2 |
--------------------------------------------------------------------------------
/components/ui/LoadingDots/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './LoadingDots';
2 |
--------------------------------------------------------------------------------
/nhost/emails/fr/email-confirm-change/subject.txt:
--------------------------------------------------------------------------------
1 | Changez votre adresse courriel
2 |
--------------------------------------------------------------------------------
/nhost/emails/fr/password-reset/subject.txt:
--------------------------------------------------------------------------------
1 | Réinitialiser votre mot de passe
2 |
--------------------------------------------------------------------------------
/public/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nhost/nextjs-stripe-starter/HEAD/public/demo.png
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nhost/nextjs-stripe-starter/HEAD/public/og.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nhost/nextjs-stripe-starter/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/nhost/migrations/default/1669308895353_create_table_public_profile/down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE "public"."profile";
2 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669316801508_create_table_public_products/down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE "public"."products";
2 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/public_customers.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: customers
3 | schema: public
4 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 |
2 | module.exports = {
3 | plugins: {
4 | tailwindcss: {},
5 | autoprefixer: {}
6 | }
7 | };
--------------------------------------------------------------------------------
/nhost/migrations/default/1669976516108_rename_table_public_products/down.sql:
--------------------------------------------------------------------------------
1 | alter table "public"."plans" rename to "products";
2 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669976516108_rename_table_public_products/up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE
2 | "public"."products" RENAME TO "plans";
--------------------------------------------------------------------------------
/nhost/metadata/actions.yaml:
--------------------------------------------------------------------------------
1 | actions: []
2 | custom_types:
3 | enums: []
4 | input_objects: []
5 | objects: []
6 | scalars: []
7 |
--------------------------------------------------------------------------------
/utils/react-query-client.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from '@tanstack/react-query';
2 |
3 | export const queryClient = new QueryClient();
4 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669325785961_run_sql_migration/up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE
2 | public.products
3 | ADD
4 | COLUMN description text DEFAULT '';
--------------------------------------------------------------------------------
/functions/_utils/graphql/plans.graphql:
--------------------------------------------------------------------------------
1 | query getPlans($where: plans_bool_exp!) {
2 | plans(where: $where) {
3 | id
4 | name
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/functions/test.js:
--------------------------------------------------------------------------------
1 | export default (req, res) => {
2 | console.log('console log test');
3 | res.status(200).send(`Hello ${req.query.name}!`);
4 | };
5 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669975961205_alter_table_public_profiles_add_column_plan_id/down.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE public.profiles DROP COLUMN IF EXISTS plan_id;
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: [
3 | './pages/**/*.{js,ts,jsx,tsx}',
4 | './components/**/*.{js,ts,jsx,tsx}'
5 | ]
6 | };
7 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669975970500_alter_table_public_products_alter_column_price_id/down.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE public.products RENAME COLUMN stripe_price_id TO price_id;
2 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669975970500_alter_table_public_products_alter_column_price_id/up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE
2 | public.products RENAME COLUMN price_id TO stripe_price_id;
--------------------------------------------------------------------------------
/nhost/migrations/default/1669317002587_alter_table_public_products_alter_column_price_id/down.sql:
--------------------------------------------------------------------------------
1 | alter table "public"."products" drop constraint "products_price_id_key";
2 |
--------------------------------------------------------------------------------
/functions/_utils/stripe.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 |
3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
4 | apiVersion: '2020-08-27'
5 | });
6 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669317002587_alter_table_public_products_alter_column_price_id/up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE
2 | "public"."products"
3 | ADD
4 | CONSTRAINT "products_price_id_key" UNIQUE ("price_id");
--------------------------------------------------------------------------------
/.env.development.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_NHOST_SUBDOMAIN=localhost
2 | NEXT_PUBLIC_NHOST_REGION=
3 |
4 | # Stripe
5 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_123
6 | STRIPE_SECRET_KEY=sk_test_123
7 | STRIPE_WEBHOOK_SECRET=whsec_123
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/components/ui/Input/Input.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply bg-black py-2 px-3 w-full appearance-none transition duration-150 ease-in-out border border-zinc-500 text-zinc-200;
3 | }
4 |
5 | .root:focus {
6 | @apply outline-none;
7 | }
8 |
--------------------------------------------------------------------------------
/utils/nhost.ts:
--------------------------------------------------------------------------------
1 | import { NhostClient } from '@nhost/nextjs';
2 |
3 | const nhost = new NhostClient({
4 | subdomain: process.env.NEXT_PUBLIC_NHOST_SUBDOMAIN,
5 | region: process.env.NEXT_PUBLIC_NHOST_REGION
6 | });
7 |
8 | export { nhost };
9 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669325785961_run_sql_migration/down.sql:
--------------------------------------------------------------------------------
1 | -- Could not auto-generate a down migration.
2 | -- Please write an appropriate down migration for the SQL below:
3 | -- ALTER TABLE public.products ADD COLUMN description text DEFAULT '';
4 |
--------------------------------------------------------------------------------
/graphql/stripe.graphql:
--------------------------------------------------------------------------------
1 | mutation createCustomerPortalSession(
2 | $customerId: String!
3 | $returnUrl: String!
4 | ) {
5 | stripe {
6 | createBillingPortalSession(customer: $customerId, returnUrl: $returnUrl) {
7 | id
8 | url
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/components/ui/LoadingDots/LoadingDots.tsx:
--------------------------------------------------------------------------------
1 | import s from './LoadingDots.module.css';
2 |
3 | const LoadingDots = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | );
11 | };
12 |
13 | export default LoadingDots;
14 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669975961205_alter_table_public_profiles_add_column_plan_id/up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE
2 | public.profiles
3 | ADD
4 | plan_id uuid;
5 |
6 | ALTER TABLE
7 | public.profiles
8 | ADD
9 | CONSTRAINT profiles_plan_id_fkey FOREIGN KEY (plan_id) REFERENCES public.products (id) ON UPDATE RESTRICT ON DELETE RESTRICT;
--------------------------------------------------------------------------------
/nhost/emails/en/email-verify/body.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Verify Email
10 | Use this link to verify your email:
11 |
12 |
13 | Verify Email
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/nhost/emails/en/signin-passwordless/body.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Magic Link
10 | Use this link to securely sign in:
11 |
12 |
13 | Sign In
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/nhost/emails/en/password-reset/body.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Reset Password
10 | Use this link to reset your password:
11 |
12 |
13 | Reset password
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/nhost/emails/en/email-confirm-change/body.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Confirm Email Change
10 | Use this link to confirm changing email:
11 |
12 |
13 | Change email
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/functions/_utils/graphql/users.graphql:
--------------------------------------------------------------------------------
1 | query getUser($id: uuid!) {
2 | user(id: $id) {
3 | id
4 | email
5 | displayName
6 | profile {
7 | id
8 | stripeCustomerId
9 | stripeCustomer {
10 | subscriptions {
11 | data {
12 | id
13 | }
14 | }
15 | }
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/nhost/migrations/default/1669308895353_create_table_public_profile/up.sql:
--------------------------------------------------------------------------------
1 | CREATE EXTENSION IF NOT EXISTS pgcrypto;
2 |
3 | CREATE TABLE "public"."profiles" (
4 | "id" uuid NOT NULL DEFAULT gen_random_uuid(),
5 | "stripe_customer_id" text,
6 | PRIMARY KEY ("id"),
7 | FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON UPDATE RESTRICT ON DELETE CASCADE
8 | );
9 |
--------------------------------------------------------------------------------
/nhost/emails/fr/signin-passwordless/body.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Lien magique
10 | Utilisez ce lien pour vous connecter de façon sécuritaire:
11 |
12 |
13 | Connexion
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/nhost/emails/fr/email-verify/body.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Vérifiez votre courriel
10 | Utilisez ce lien pour vérifier votre courriel:
11 |
12 |
13 | Vérifier courriel
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/nhost/emails/fr/email-confirm-change/body.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Confirmer changement de courriel
10 | Utilisez ce lien pour confirmer le changement de courriel:
11 |
12 |
13 | Changer courriel
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/styles/chrome-bug.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Chrome has a bug with transitions on load since 2012!
3 | *
4 | * To prevent a "pop" of content, you have to disable all transitions until
5 | * the page is done loading.
6 | *
7 | * https://lab.laukstein.com/bug/input
8 | * https://twitter.com/timer150/status/1345217126680899584
9 | */
10 | body.loading * {
11 | transition: none !important;
12 | }
13 |
--------------------------------------------------------------------------------
/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | import { Price } from 'types';
2 |
3 | export const getURL = () => {
4 | const url =
5 | process?.env?.URL && process.env.URL !== ''
6 | ? process.env.URL
7 | : process?.env?.VERCEL_URL && process.env.VERCEL_URL !== ''
8 | ? process.env.VERCEL_URL
9 | : 'http://localhost:3000';
10 | return url.includes('http') ? url : `https://${url}`;
11 | };
12 |
--------------------------------------------------------------------------------
/nhost/emails/fr/password-reset/body.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Réinitializer votre mot de passe
10 | Utilisez ce lien pour réinitializer votre mot de passe:
11 |
12 |
13 | Réinitializer mot de passe
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from 'next/document';
2 |
3 | class MyDocument extends Document {
4 | render() {
5 | return (
6 |
7 |
9 |
10 |
11 |
12 |
13 | );
14 | }
15 | }
16 |
17 | export default MyDocument;
18 |
--------------------------------------------------------------------------------
/functions/_utils/graphql/profiles.graphql:
--------------------------------------------------------------------------------
1 | mutation InsertProfile($profile: profiles_insert_input!) {
2 | insertProfile(object: $profile) {
3 | id
4 | }
5 | }
6 |
7 | mutation UpdateProfileUsingStripeCustomerId(
8 | $stripeCustomerId: String!
9 | $profile: profiles_set_input!
10 | ) {
11 | update_profiles(
12 | where: { stripeCustomerId: { _eq: $stripeCustomerId } }
13 | _set: $profile
14 | ) {
15 | affected_rows
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/databases.yaml:
--------------------------------------------------------------------------------
1 | - name: default
2 | kind: postgres
3 | configuration:
4 | connection_info:
5 | database_url:
6 | from_env: HASURA_GRAPHQL_DATABASE_URL
7 | isolation_level: read-committed
8 | pool_settings:
9 | connection_lifetime: 600
10 | idle_timeout: 180
11 | max_connections: 50
12 | retries: 20
13 | use_prepared_statements: true
14 | tables: "!include default/tables/tables.yaml"
15 |
--------------------------------------------------------------------------------
/functions/_utils/graphql-client.ts:
--------------------------------------------------------------------------------
1 | import { GraphQLClient } from 'graphql-request';
2 | import { getSdk } from './__generated__/graphql-request';
3 |
4 | const endpoint = `${process.env.NHOST_BACKEND_URL}/v1/graphql`;
5 |
6 | // This GraphQL Client is only used in serverless functions (secure).
7 | const client = new GraphQLClient(endpoint, {
8 | headers: {
9 | ['x-hasura-admin-secret']: process.env.NHOST_ADMIN_SECRET as string
10 | }
11 | });
12 |
13 | export const sdk = getSdk(client);
14 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/tables.yaml:
--------------------------------------------------------------------------------
1 | - "!include auth_provider_requests.yaml"
2 | - "!include auth_providers.yaml"
3 | - "!include auth_refresh_tokens.yaml"
4 | - "!include auth_roles.yaml"
5 | - "!include auth_user_providers.yaml"
6 | - "!include auth_user_roles.yaml"
7 | - "!include auth_user_security_keys.yaml"
8 | - "!include auth_users.yaml"
9 | - "!include public_plans.yaml"
10 | - "!include public_profiles.yaml"
11 | - "!include storage_buckets.yaml"
12 | - "!include storage_files.yaml"
13 |
--------------------------------------------------------------------------------
/components/ui/Navbar/Navbar.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply sticky top-0 bg-black z-40 transition-all duration-150;
3 | }
4 |
5 | .link {
6 | @apply inline-flex items-center leading-6 font-medium transition ease-in-out duration-75 cursor-pointer text-zinc-200 rounded-md p-1;
7 | }
8 |
9 | .link:hover {
10 | @apply text-zinc-100;
11 | }
12 |
13 | .link:focus {
14 | @apply outline-none text-zinc-100 ring-2 ring-pink-500 ring-opacity-50;
15 | }
16 |
17 | .logo {
18 | @apply cursor-pointer rounded-full transform duration-100 ease-in-out;
19 | }
20 |
--------------------------------------------------------------------------------
/functions/graphql/stripe.ts:
--------------------------------------------------------------------------------
1 | import { Context, createStripeGraphQLServer } from '@nhost/stripe-graphql-js';
2 |
3 | const isAllowed = async (stripeCustomerId: string, context: Context) => {
4 | const { isAdmin, userClaims } = context;
5 |
6 | if (isAdmin) {
7 | return true;
8 | }
9 |
10 | //TODO: Make sure the user can only access their own stripe customer id
11 |
12 | return true;
13 | };
14 |
15 | process.env.NODE_ENVIRONMENT = 'development';
16 | const server = createStripeGraphQLServer({ isAllowed });
17 |
18 | export default server;
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env.local
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | # editors
37 | .vscode
38 |
39 | .nhost
40 | .env.development
41 | functions/node_modules
--------------------------------------------------------------------------------
/components/ui/LoadingDots/LoadingDots.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply inline-flex text-center items-center leading-7;
3 | }
4 |
5 | .root span {
6 | @apply bg-zinc-200 rounded-full h-2 w-2;
7 | animation-name: blink;
8 | animation-duration: 1.4s;
9 | animation-iteration-count: infinite;
10 | animation-fill-mode: both;
11 | margin: 0 2px;
12 | }
13 |
14 | .root span:nth-of-type(2) {
15 | animation-delay: 0.2s;
16 | }
17 |
18 | .root span:nth-of-type(3) {
19 | animation-delay: 0.4s;
20 | }
21 |
22 | @keyframes blink {
23 | 0% {
24 | opacity: 0.2;
25 | }
26 | 20% {
27 | opacity: 1;
28 | }
29 | 100% {
30 | opacity: 0.2;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/components/icons/Logo.tsx:
--------------------------------------------------------------------------------
1 | const Logo = ({ className = '', ...props }) => (
2 |
19 | );
20 |
21 | export default Logo;
22 |
--------------------------------------------------------------------------------
/graphql/plans.graphql:
--------------------------------------------------------------------------------
1 | fragment plan on plans {
2 | id
3 | name
4 | description
5 | amount
6 | currency
7 | stripePriceId
8 | }
9 |
10 | fragment UserWithSubscription on users {
11 | id
12 | profile {
13 | id
14 | stripeCustomer {
15 | subscriptions {
16 | data {
17 | id
18 | items {
19 | data {
20 | id
21 | price {
22 | id
23 | }
24 | }
25 | }
26 | }
27 | }
28 | }
29 | }
30 | }
31 |
32 | query getPlansAndSubscription($userId: uuid!) {
33 | plans {
34 | ...plan
35 | }
36 | user(id: $userId) {
37 | ...UserWithSubscription
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/graphql.config.yaml:
--------------------------------------------------------------------------------
1 | schema:
2 | - http://localhost:1337/v1/graphql:
3 | headers:
4 | x-hasura-admin-secret: nhost-admin-secret
5 | generates:
6 | ./utils/__generated__/graphql.ts:
7 | documents:
8 | - './**/*.graphql'
9 | - '!functions/**/*.graphql'
10 | plugins:
11 | - 'typescript'
12 | - 'typescript-operations'
13 | - 'typescript-react-query'
14 | config:
15 | fetcher:
16 | func: '../graphql-fetcher#fetchData'
17 | isReactHook: false
18 | functions/_utils/__generated__/graphql-request.ts:
19 | documents:
20 | - 'functions/**/*.graphql'
21 | plugins:
22 | - 'typescript'
23 | - 'typescript-operations'
24 | - 'typescript-graphql-request'
25 |
--------------------------------------------------------------------------------
/nhost/seeds/default/001-plans.sql:
--------------------------------------------------------------------------------
1 | -- update the price_id with the price id from stripe
2 | -- amount is in cents
3 | INSERT INTO
4 | public.plans (name, stripe_price_id, amount, currency, description)
5 | VALUES
6 | (
7 | 'Hobby',
8 | 'price_1M82BOCCF9wuB4fX3BplVWB9',
9 | 1200,
10 | 'USD',
11 | 'Hobby Description'
12 | ),
13 | (
14 | 'Startup',
15 | 'price_1M82BuCCF9wuB4fXhdtUU6Pk',
16 | 2400,
17 | 'USD',
18 | 'Startup Description'
19 | ),
20 | (
21 | 'Pro',
22 | 'price_1M82CACCF9wuB4fXZ019vYFb',
23 | 3200,
24 | 'USD',
25 | 'Pro Description'
26 | ),
27 | (
28 | 'Enterprise',
29 | 'price_1M82CTCCF9wuB4fXA9Sg5jyT',
30 | 4800,
31 | 'USD',
32 | 'Enterprise Description'
33 | );
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/auth_provider_requests.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: provider_requests
3 | schema: auth
4 | configuration:
5 | column_config:
6 | id:
7 | custom_name: id
8 | options:
9 | custom_name: options
10 | custom_column_names:
11 | id: id
12 | options: options
13 | custom_name: authProviderRequests
14 | custom_root_fields:
15 | delete: deleteAuthProviderRequests
16 | delete_by_pk: deleteAuthProviderRequest
17 | insert: insertAuthProviderRequests
18 | insert_one: insertAuthProviderRequest
19 | select: authProviderRequests
20 | select_aggregate: authProviderRequestsAggregate
21 | select_by_pk: authProviderRequest
22 | update: updateAuthProviderRequests
23 | update_by_pk: updateAuthProviderRequest
24 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/public_profile.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: profile
3 | schema: public
4 | object_relationships:
5 | - name: user
6 | using:
7 | foreign_key_constraint_on: id
8 | remote_relationships:
9 | - definition:
10 | to_remote_schema:
11 | lhs_fields:
12 | - stripe_customer_id
13 | remote_field:
14 | stripe:
15 | arguments: {}
16 | field:
17 | customer:
18 | arguments:
19 | id: $stripe_customer_id
20 | remote_schema: stripe
21 | name: stripeCustomer
22 | select_permissions:
23 | - role: user
24 | permission:
25 | columns:
26 | - stripe_customer_id
27 | - id
28 | filter:
29 | id:
30 | _eq: X-Hasura-User-Id
31 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import LoadingDots from '@/components/ui/LoadingDots';
2 | import { nhost } from '@/utils/nhost';
3 | import { useGetPlansAndSubscriptionQuery } from '@/utils/__generated__/graphql';
4 | import Pricing from 'components/Pricing';
5 |
6 | export default function PricingPage() {
7 | const user = nhost.auth.getUser();
8 |
9 | const { data, isLoading } = useGetPlansAndSubscriptionQuery({
10 | userId: user?.id
11 | });
12 |
13 | if (isLoading) {
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | if (!data) {
22 | return Failed to load data
;
23 | }
24 |
25 | const { plans, user: userWithSubscription } = data;
26 |
27 | return ;
28 | }
29 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/auth_providers.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: providers
3 | schema: auth
4 | configuration:
5 | column_config:
6 | id:
7 | custom_name: id
8 | custom_column_names:
9 | id: id
10 | custom_name: authProviders
11 | custom_root_fields:
12 | delete: deleteAuthProviders
13 | delete_by_pk: deleteAuthProvider
14 | insert: insertAuthProviders
15 | insert_one: insertAuthProvider
16 | select: authProviders
17 | select_aggregate: authProvidersAggregate
18 | select_by_pk: authProvider
19 | update: updateAuthProviders
20 | update_by_pk: updateAuthProvider
21 | array_relationships:
22 | - name: userProviders
23 | using:
24 | foreign_key_constraint_on:
25 | column: provider_id
26 | table:
27 | name: user_providers
28 | schema: auth
29 |
--------------------------------------------------------------------------------
/graphql/user.graphql:
--------------------------------------------------------------------------------
1 | query getUser($id: uuid!) {
2 | user(id: $id) {
3 | id
4 | profile {
5 | id
6 | stripeCustomerId
7 | stripeCustomer {
8 | invoices {
9 | data {
10 | id
11 | created
12 | invoicePdf
13 | hostedInvoiceUrl
14 | paid
15 | }
16 | }
17 | subscriptions {
18 | data {
19 | id
20 | items {
21 | data {
22 | id
23 | price {
24 | id
25 | unitAmount
26 | currency
27 | product {
28 | id
29 | name
30 | }
31 | }
32 | }
33 | }
34 | }
35 | }
36 | }
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "baseUrl": ".",
21 | "paths": {
22 | "@/components/*": ["components/*"],
23 | "@/utils/*": ["utils/*"],
24 | "@/styles/*": ["styles/*"]
25 | },
26 | "incremental": true
27 | },
28 | "include": [
29 | "next-env.d.ts",
30 | "**/*.ts",
31 | "**/*.tsx"
32 | ],
33 | "exclude": [
34 | "node_modules"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/public_plans.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: plans
3 | schema: public
4 | configuration:
5 | column_config:
6 | created_at:
7 | custom_name: createdAt
8 | stripe_price_id:
9 | custom_name: stripePriceId
10 | updated_at:
11 | custom_name: updatedAt
12 | custom_column_names:
13 | created_at: createdAt
14 | stripe_price_id: stripePriceId
15 | updated_at: updatedAt
16 | custom_root_fields: {}
17 | array_relationships:
18 | - name: profiles
19 | using:
20 | foreign_key_constraint_on:
21 | column: plan_id
22 | table:
23 | name: profiles
24 | schema: public
25 | select_permissions:
26 | - role: user
27 | permission:
28 | columns:
29 | - amount
30 | - currency
31 | - description
32 | - id
33 | - name
34 | - stripe_price_id
35 | filter: {}
36 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/public_products.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: products
3 | schema: public
4 | configuration:
5 | column_config:
6 | created_at:
7 | custom_name: createdAt
8 | stripe_price_id:
9 | custom_name: stripePriceId
10 | updated_at:
11 | custom_name: updatedAt
12 | custom_column_names:
13 | created_at: createdAt
14 | stripe_price_id: stripePriceId
15 | updated_at: updatedAt
16 | custom_root_fields: {}
17 | array_relationships:
18 | - name: profiles
19 | using:
20 | foreign_key_constraint_on:
21 | column: plan_id
22 | table:
23 | name: profiles
24 | schema: public
25 | select_permissions:
26 | - role: user
27 | permission:
28 | columns:
29 | - amount
30 | - currency
31 | - description
32 | - id
33 | - name
34 | - stripe_price_id
35 | filter: {}
36 |
--------------------------------------------------------------------------------
/components/ui/Button/Button.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | @apply bg-white text-zinc-800 cursor-pointer inline-flex px-10 rounded-sm leading-6 transition ease-in-out duration-150 shadow-sm font-semibold text-center justify-center uppercase py-4 border border-transparent items-center;
3 | }
4 |
5 | .root:hover {
6 | @apply bg-zinc-800 text-white border border-white;
7 | }
8 |
9 | .root:focus {
10 | @apply outline-none ring-2 ring-pink-500 ring-opacity-50;
11 | }
12 |
13 | .root[data-active] {
14 | @apply bg-zinc-600;
15 | }
16 |
17 | .loading {
18 | @apply bg-zinc-700 text-zinc-500 border-zinc-600 cursor-not-allowed;
19 | }
20 |
21 | .slim {
22 | @apply py-2 transform-none normal-case;
23 | }
24 |
25 | .disabled,
26 | .disabled:hover {
27 | @apply text-zinc-400 border-zinc-600 bg-zinc-700 cursor-not-allowed;
28 | filter: grayscale(1);
29 | -webkit-transform: translateZ(0);
30 | -webkit-perspective: 1000;
31 | -webkit-backface-visibility: hidden;
32 | }
33 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/auth_roles.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: roles
3 | schema: auth
4 | configuration:
5 | column_config:
6 | role:
7 | custom_name: role
8 | custom_column_names:
9 | role: role
10 | custom_name: authRoles
11 | custom_root_fields:
12 | delete: deleteAuthRoles
13 | delete_by_pk: deleteAuthRole
14 | insert: insertAuthRoles
15 | insert_one: insertAuthRole
16 | select: authRoles
17 | select_aggregate: authRolesAggregate
18 | select_by_pk: authRole
19 | update: updateAuthRoles
20 | update_by_pk: updateAuthRole
21 | array_relationships:
22 | - name: userRoles
23 | using:
24 | foreign_key_constraint_on:
25 | column: role
26 | table:
27 | name: user_roles
28 | schema: auth
29 | - name: usersByDefaultRole
30 | using:
31 | foreign_key_constraint_on:
32 | column: default_role
33 | table:
34 | name: users
35 | schema: auth
36 |
--------------------------------------------------------------------------------
/components/ui/Input/Input.tsx:
--------------------------------------------------------------------------------
1 | import React, { InputHTMLAttributes, ChangeEvent } from 'react';
2 | import cn from 'classnames';
3 | import s from './Input.module.css';
4 |
5 | interface Props extends Omit, 'onChange'> {
6 | className?: string;
7 | onChange: (value: string) => void;
8 | }
9 | const Input = (props: Props) => {
10 | const { className, children, onChange, ...rest } = props;
11 |
12 | const rootClassName = cn(s.root, {}, className);
13 |
14 | const handleOnChange = (e: ChangeEvent) => {
15 | if (onChange) {
16 | onChange(e.target.value);
17 | }
18 | return null;
19 | };
20 |
21 | return (
22 |
33 | );
34 | };
35 |
36 | export default Input;
37 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/auth_user_roles.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: user_roles
3 | schema: auth
4 | configuration:
5 | column_config:
6 | created_at:
7 | custom_name: createdAt
8 | id:
9 | custom_name: id
10 | role:
11 | custom_name: role
12 | user_id:
13 | custom_name: userId
14 | custom_column_names:
15 | created_at: createdAt
16 | id: id
17 | role: role
18 | user_id: userId
19 | custom_name: authUserRoles
20 | custom_root_fields:
21 | delete: deleteAuthUserRoles
22 | delete_by_pk: deleteAuthUserRole
23 | insert: insertAuthUserRoles
24 | insert_one: insertAuthUserRole
25 | select: authUserRoles
26 | select_aggregate: authUserRolesAggregate
27 | select_by_pk: authUserRole
28 | update: updateAuthUserRoles
29 | update_by_pk: updateAuthUserRole
30 | object_relationships:
31 | - name: roleByRole
32 | using:
33 | foreign_key_constraint_on: role
34 | - name: user
35 | using:
36 | foreign_key_constraint_on: user_id
37 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import 'styles/main.css';
2 | import 'styles/chrome-bug.css';
3 |
4 | import React from 'react';
5 |
6 | import Layout from 'components/Layout';
7 | import { AppProps } from 'next/app';
8 |
9 | import { NhostNextProvider, SignedIn, SignedOut } from '@nhost/nextjs';
10 | import { nhost } from '@/utils/nhost';
11 | import { QueryClientProvider } from '@tanstack/react-query';
12 | import { queryClient } from '@/utils/react-query-client';
13 | import SignIn from './signin';
14 |
15 | export default function MyApp({ Component, pageProps }: AppProps) {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/auth_refresh_tokens.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: refresh_tokens
3 | schema: auth
4 | configuration:
5 | column_config:
6 | created_at:
7 | custom_name: createdAt
8 | expires_at:
9 | custom_name: expiresAt
10 | refresh_token:
11 | custom_name: refreshToken
12 | user_id:
13 | custom_name: userId
14 | custom_column_names:
15 | created_at: createdAt
16 | expires_at: expiresAt
17 | refresh_token: refreshToken
18 | user_id: userId
19 | custom_name: authRefreshTokens
20 | custom_root_fields:
21 | delete: deleteAuthRefreshTokens
22 | delete_by_pk: deleteAuthRefreshToken
23 | insert: insertAuthRefreshTokens
24 | insert_one: insertAuthRefreshToken
25 | select: authRefreshTokens
26 | select_aggregate: authRefreshTokensAggregate
27 | select_by_pk: authRefreshToken
28 | update: updateAuthRefreshTokens
29 | update_by_pk: updateAuthRefreshToken
30 | object_relationships:
31 | - name: user
32 | using:
33 | foreign_key_constraint_on: user_id
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Vercel, Inc.
4 |
5 | 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:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | 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.
--------------------------------------------------------------------------------
/nhost/migrations/default/1669316801508_create_table_public_products/up.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "public"."products" (
2 | "id" uuid NOT NULL DEFAULT gen_random_uuid(),
3 | "created_at" timestamptz NOT NULL DEFAULT NOW(),
4 | "updated_at" timestamptz NOT NULL DEFAULT NOW(),
5 | "name" text NOT NULL,
6 | "price_id" text NOT NULL,
7 | "amount" bigint NOT NULL,
8 | "currency" text NOT NULL,
9 | PRIMARY KEY ("id")
10 | );
11 |
12 | CREATE
13 | OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() RETURNS TRIGGER AS $$ DECLARE _new record;
14 |
15 | BEGIN _new := NEW;
16 |
17 | _new."updated_at" = NOW();
18 |
19 | RETURN _new;
20 |
21 | END;
22 |
23 | $$ LANGUAGE plpgsql;
24 |
25 | CREATE TRIGGER "set_public_products_updated_at" BEFORE
26 | UPDATE
27 | ON "public"."products" FOR EACH ROW EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"();
28 |
29 | COMMENT ON TRIGGER "set_public_products_updated_at" ON "public"."products" IS 'trigger to set value of column "updated_at" to current timestamp on row update';
30 |
31 | CREATE EXTENSION IF NOT EXISTS pgcrypto;
--------------------------------------------------------------------------------
/utils/graphql-fetcher.ts:
--------------------------------------------------------------------------------
1 | import { nhost } from './nhost';
2 |
3 | type AuthHeaderProps = {
4 | authorization: string;
5 | };
6 |
7 | export const fetchData = (
8 | query: string,
9 | variables?: TVariables,
10 | options?: RequestInit['headers']
11 | ): (() => Promise) => {
12 | return async () => {
13 | const authHeaders = {} as AuthHeaderProps;
14 |
15 | if (nhost.auth.isAuthenticated()) {
16 | authHeaders['authorization'] = `Bearer ${nhost.auth.getAccessToken()}`;
17 | }
18 |
19 | const res = await fetch(nhost.graphql.getUrl(), {
20 | method: 'POST',
21 | headers: {
22 | 'Content-Type': 'application/json',
23 | ...authHeaders,
24 | ...(options ?? {})
25 | },
26 | body: JSON.stringify({
27 | query,
28 | variables
29 | })
30 | });
31 |
32 | const json = await res.json();
33 |
34 | if (json.errors) {
35 | const { message } = json.errors[0] || 'Error..';
36 | throw new Error(message);
37 | }
38 |
39 | return json.data;
40 | };
41 | };
42 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/auth_user_security_keys.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: user_security_keys
3 | schema: auth
4 | configuration:
5 | column_config:
6 | credential_id:
7 | custom_name: credentialId
8 | credential_public_key:
9 | custom_name: credentialPublicKey
10 | id:
11 | custom_name: id
12 | user_id:
13 | custom_name: userId
14 | custom_column_names:
15 | credential_id: credentialId
16 | credential_public_key: credentialPublicKey
17 | id: id
18 | user_id: userId
19 | custom_name: authUserSecurityKeys
20 | custom_root_fields:
21 | delete: deleteAuthUserSecurityKeys
22 | delete_by_pk: deleteAuthUserSecurityKey
23 | insert: insertAuthUserSecurityKeys
24 | insert_one: insertAuthUserSecurityKey
25 | select: authUserSecurityKeys
26 | select_aggregate: authUserSecurityKeysAggregate
27 | select_by_pk: authUserSecurityKey
28 | update: updateAuthUserSecurityKeys
29 | update_by_pk: updateAuthUserSecurityKey
30 | object_relationships:
31 | - name: user
32 | using:
33 | foreign_key_constraint_on: user_id
34 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/public_profiles.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: profiles
3 | schema: public
4 | configuration:
5 | column_config:
6 | stripe_customer_id:
7 | custom_name: stripeCustomerId
8 | custom_column_names:
9 | stripe_customer_id: stripeCustomerId
10 | custom_root_fields:
11 | insert: insertProfiles
12 | insert_one: insertProfile
13 | object_relationships:
14 | - name: plan
15 | using:
16 | foreign_key_constraint_on: plan_id
17 | - name: user
18 | using:
19 | foreign_key_constraint_on: id
20 | remote_relationships:
21 | - definition:
22 | to_remote_schema:
23 | lhs_fields:
24 | - stripe_customer_id
25 | remote_field:
26 | stripe:
27 | arguments: {}
28 | field:
29 | customer:
30 | arguments:
31 | id: $stripe_customer_id
32 | remote_schema: stripe
33 | name: stripeCustomer
34 | select_permissions:
35 | - role: user
36 | permission:
37 | columns:
38 | - stripe_customer_id
39 | - id
40 | filter:
41 | id:
42 | _eq: X-Hasura-User-Id
43 |
--------------------------------------------------------------------------------
/styles/main.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | *,
6 | *:before,
7 | *:after {
8 | box-sizing: inherit;
9 | }
10 |
11 | *:focus {
12 | @apply outline-none ring-2 ring-pink-500 ring-opacity-50;
13 | }
14 |
15 | html {
16 | height: 100%;
17 | box-sizing: border-box;
18 | touch-action: manipulation;
19 | font-feature-settings: 'case' 1, 'rlig' 1, 'calt' 0;
20 | }
21 |
22 | html,
23 | body {
24 | font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Helvetica Neue',
25 | 'Helvetica', sans-serif;
26 | text-rendering: optimizeLegibility;
27 | -moz-osx-font-smoothing: grayscale;
28 | @apply text-white bg-zinc-800 antialiased;
29 | }
30 |
31 | body {
32 | position: relative;
33 | min-height: 100%;
34 | margin: 0;
35 | }
36 |
37 | a {
38 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
39 | }
40 |
41 | .animated {
42 | -webkit-animation-duration: 1s;
43 | animation-duration: 1s;
44 | -webkit-animation-duration: 1s;
45 | animation-duration: 1s;
46 | -webkit-animation-fill-mode: both;
47 | animation-fill-mode: both;
48 | }
49 |
50 | .height-screen-helper {
51 | height: calc(100vh - 80px);
52 | }
53 |
--------------------------------------------------------------------------------
/functions/events/users/insert/stripe.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import { sdk } from '../../../_utils/graphql-client';
3 | import { stripe } from '../../../_utils/stripe';
4 |
5 | const handler = async (req: Request, res: Response) => {
6 | const { body } = req;
7 |
8 | const { user } = await sdk.getUser({
9 | id: body.event.data.new.id
10 | });
11 |
12 | // check if the user exists
13 | if (!user) {
14 | return res.status(400).send('User not found');
15 | }
16 |
17 | // check if the user already has a stripe customer attached
18 | if (user.profile?.stripeCustomerId) {
19 | return res.status(200).send('User already has a stripe customer attached');
20 | }
21 |
22 | // create stripe customer
23 | const stripeCustomer = await stripe.customers.create({
24 | name: user.displayName,
25 | email: user.email,
26 | metadata: {
27 | userId: user.id
28 | }
29 | });
30 |
31 | // insert profile into database
32 | await sdk.InsertProfile({
33 | profile: {
34 | id: user.id,
35 | stripeCustomerId: stripeCustomer.id
36 | }
37 | });
38 |
39 | res.sendStatus(204);
40 | };
41 |
42 | export default handler;
43 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/storage_files.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: files
3 | schema: storage
4 | configuration:
5 | column_config:
6 | bucket_id:
7 | custom_name: bucketId
8 | created_at:
9 | custom_name: createdAt
10 | etag:
11 | custom_name: etag
12 | id:
13 | custom_name: id
14 | is_uploaded:
15 | custom_name: isUploaded
16 | mime_type:
17 | custom_name: mimeType
18 | name:
19 | custom_name: name
20 | size:
21 | custom_name: size
22 | updated_at:
23 | custom_name: updatedAt
24 | uploaded_by_user_id:
25 | custom_name: uploadedByUserId
26 | custom_column_names:
27 | bucket_id: bucketId
28 | created_at: createdAt
29 | etag: etag
30 | id: id
31 | is_uploaded: isUploaded
32 | mime_type: mimeType
33 | name: name
34 | size: size
35 | updated_at: updatedAt
36 | uploaded_by_user_id: uploadedByUserId
37 | custom_name: files
38 | custom_root_fields:
39 | delete: deleteFiles
40 | delete_by_pk: deleteFile
41 | insert: insertFiles
42 | insert_one: insertFile
43 | select: files
44 | select_aggregate: filesAggregate
45 | select_by_pk: file
46 | update: updateFiles
47 | update_by_pk: updateFile
48 | object_relationships:
49 | - name: bucket
50 | using:
51 | foreign_key_constraint_on: bucket_id
52 |
--------------------------------------------------------------------------------
/components/icons/GitHub.tsx:
--------------------------------------------------------------------------------
1 | const GitHub = ({ ...props }) => {
2 | return (
3 |
17 | );
18 | };
19 |
20 | export default GitHub;
21 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/auth_user_providers.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: user_providers
3 | schema: auth
4 | configuration:
5 | column_config:
6 | access_token:
7 | custom_name: accessToken
8 | created_at:
9 | custom_name: createdAt
10 | id:
11 | custom_name: id
12 | provider_id:
13 | custom_name: providerId
14 | provider_user_id:
15 | custom_name: providerUserId
16 | refresh_token:
17 | custom_name: refreshToken
18 | updated_at:
19 | custom_name: updatedAt
20 | user_id:
21 | custom_name: userId
22 | custom_column_names:
23 | access_token: accessToken
24 | created_at: createdAt
25 | id: id
26 | provider_id: providerId
27 | provider_user_id: providerUserId
28 | refresh_token: refreshToken
29 | updated_at: updatedAt
30 | user_id: userId
31 | custom_name: authUserProviders
32 | custom_root_fields:
33 | delete: deleteAuthUserProviders
34 | delete_by_pk: deleteAuthUserProvider
35 | insert: insertAuthUserProviders
36 | insert_one: insertAuthUserProvider
37 | select: authUserProviders
38 | select_aggregate: authUserProvidersAggregate
39 | select_by_pk: authUserProvider
40 | update: updateAuthUserProviders
41 | update_by_pk: updateAuthUserProvider
42 | object_relationships:
43 | - name: provider
44 | using:
45 | foreign_key_constraint_on: provider_id
46 | - name: user
47 | using:
48 | foreign_key_constraint_on: user_id
49 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/storage_buckets.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: buckets
3 | schema: storage
4 | configuration:
5 | column_config:
6 | cache_control:
7 | custom_name: cacheControl
8 | created_at:
9 | custom_name: createdAt
10 | download_expiration:
11 | custom_name: downloadExpiration
12 | id:
13 | custom_name: id
14 | max_upload_file_size:
15 | custom_name: maxUploadFileSize
16 | min_upload_file_size:
17 | custom_name: minUploadFileSize
18 | presigned_urls_enabled:
19 | custom_name: presignedUrlsEnabled
20 | updated_at:
21 | custom_name: updatedAt
22 | custom_column_names:
23 | cache_control: cacheControl
24 | created_at: createdAt
25 | download_expiration: downloadExpiration
26 | id: id
27 | max_upload_file_size: maxUploadFileSize
28 | min_upload_file_size: minUploadFileSize
29 | presigned_urls_enabled: presignedUrlsEnabled
30 | updated_at: updatedAt
31 | custom_name: buckets
32 | custom_root_fields:
33 | delete: deleteBuckets
34 | delete_by_pk: deleteBucket
35 | insert: insertBuckets
36 | insert_one: insertBucket
37 | select: buckets
38 | select_aggregate: bucketsAggregate
39 | select_by_pk: bucket
40 | update: updateBuckets
41 | update_by_pk: updateBucket
42 | array_relationships:
43 | - name: files
44 | using:
45 | foreign_key_constraint_on:
46 | column: bucket_id
47 | table:
48 | name: files
49 | schema: storage
50 |
--------------------------------------------------------------------------------
/components/ui/Button/Button.tsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames';
2 | import React, { forwardRef, useRef, ButtonHTMLAttributes } from 'react';
3 | import mergeRefs from 'react-merge-refs';
4 | import styles from './Button.module.css';
5 |
6 | import LoadingDots from 'components/ui/LoadingDots';
7 |
8 | interface Props extends ButtonHTMLAttributes {
9 | variant?: 'slim' | 'flat';
10 | active?: boolean;
11 | width?: number;
12 | loading?: boolean;
13 | Component?: React.ComponentType;
14 | }
15 |
16 | const Button = forwardRef((props, buttonRef) => {
17 | const {
18 | className,
19 | variant = 'flat',
20 | children,
21 | active,
22 | width,
23 | loading = false,
24 | disabled = false,
25 | style = {},
26 | Component = 'button',
27 | ...rest
28 | } = props;
29 | const ref = useRef(null);
30 | const rootClassName = cn(
31 | styles.root,
32 | {
33 | [styles.slim]: variant === 'slim',
34 | [styles.loading]: loading,
35 | [styles.disabled]: disabled
36 | },
37 | className
38 | );
39 | return (
40 |
52 | {children}
53 | {loading && (
54 |
55 |
56 |
57 | )}
58 |
59 | );
60 | });
61 |
62 | export default Button;
63 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-subscription-payments",
3 | "version": "0.0.1",
4 | "license": "MIT",
5 | "scripts": {
6 | "dev": "next",
7 | "build": "next build",
8 | "start": "next start",
9 | "codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
10 | "stripe:listen": "stripe listen --forward-to=localhost:1337/v1/functions/webhook/stripe"
11 | },
12 | "dependencies": {
13 | "@nhost/nextjs": "^1.13.3",
14 | "@nhost/stripe-graphql-js": "^0.0.6",
15 | "@stripe/stripe-js": "1.22.0",
16 | "@tanstack/react-query": "^4.16.1",
17 | "classnames": "2.3.1",
18 | "date-fns": "^2.29.3",
19 | "graphql": "^16.6.0",
20 | "graphql-request": "^5.0.0",
21 | "graphql-tag": "^2.12.6",
22 | "jsonwebtoken": "^8.5.1",
23 | "next": "^12.2.5",
24 | "react": "17.0.2",
25 | "react-dom": "17.0.2",
26 | "react-merge-refs": "1.1.0",
27 | "stripe": "8.201.0",
28 | "swr": "1.2.0",
29 | "tailwindcss": "3.0.18"
30 | },
31 | "devDependencies": {
32 | "@graphql-codegen/cli": "^2.14.0",
33 | "@graphql-codegen/typescript-graphql-request": "^4.5.8",
34 | "@graphql-codegen/typescript-operations": "^2.5.7",
35 | "@graphql-codegen/typescript-react-query": "^4.0.6",
36 | "@types/classnames": "2.3.1",
37 | "@types/express": "^4.17.14",
38 | "@types/jsonwebtoken": "^8.5.9",
39 | "@types/node": "^17.0.13",
40 | "@types/react": "^17.0.38",
41 | "autoprefixer": "^10.4.2",
42 | "postcss": "8.4.5",
43 | "prettier": "2.5.1",
44 | "typescript": "^4.5.5"
45 | },
46 | "prettier": {
47 | "arrowParens": "always",
48 | "singleQuote": true,
49 | "tabWidth": 2,
50 | "trailingComma": "none"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/functions/_utils/helpers.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express';
2 | import jwt from 'jsonwebtoken';
3 |
4 | export type UserHasuraClaims = {
5 | 'x-hasura-user-id': string;
6 | 'x-hasura-default-role': string;
7 | 'x-hasura-allowed-roles': string[];
8 | };
9 |
10 | export type User = {
11 | id: string;
12 | defaultRole: string;
13 | allowedRoles: string[];
14 | };
15 |
16 | export const getUser = (req: Request): User | null => {
17 | const authorizationHeader = req.headers['authorization'];
18 | const accessToken = authorizationHeader?.split(' ')[1];
19 | if (!accessToken) {
20 | return null;
21 | }
22 | const jwtSecret = JSON.parse(process.env.NHOST_JWT_SECRET as string);
23 | const decodedToken = jwt.verify(accessToken, jwtSecret.key) as any;
24 |
25 | const hasuraClaims = decodedToken[
26 | 'https://hasura.io/jwt/claims'
27 | ] as UserHasuraClaims;
28 |
29 | const user = {
30 | id: hasuraClaims['x-hasura-user-id'],
31 | defaultRole: hasuraClaims['x-hasura-default-role'],
32 | allowedRoles: hasuraClaims['x-hasura-allowed-roles']
33 | };
34 |
35 | return user;
36 | };
37 |
38 | export const allowCors = (fn: any) => async (req: Request, res: Response) => {
39 | res.setHeader('Access-Control-Allow-Credentials', 'true');
40 | res.setHeader('Access-Control-Allow-Origin', '*');
41 | res.setHeader(
42 | 'Access-Control-Allow-Methods',
43 | 'GET,OPTIONS,PATCH,DELETE,POST,PUT'
44 | );
45 | res.setHeader(
46 | 'Access-Control-Allow-Headers',
47 | 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'
48 | );
49 | if (req.method === 'OPTIONS') {
50 | return res.status(200).end();
51 | }
52 | return await fn(req, res);
53 | };
54 |
--------------------------------------------------------------------------------
/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import { useRouter } from 'next/router';
3 |
4 | import Navbar from 'components/ui/Navbar';
5 | import Footer from 'components/ui/Footer';
6 | import { ReactNode } from 'react';
7 | import { PageMeta } from '../types';
8 |
9 | interface Props {
10 | children: ReactNode;
11 | meta?: PageMeta;
12 | }
13 |
14 | export default function Layout({ children, meta: pageMeta }: Props) {
15 | const router = useRouter();
16 | const meta = {
17 | title: 'Next.js Subscription Starter',
18 | description: 'Brought to you by Next.js, Stripe, and Nhost.',
19 | cardImage: '/og.png',
20 | ...pageMeta
21 | };
22 |
23 | return (
24 | <>
25 |
26 | {meta.title}
27 |
28 |
29 |
30 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | {children}
47 |
48 | >
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/public/stripe.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
25 |
--------------------------------------------------------------------------------
/types.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe';
2 | export interface PageMeta {
3 | title: string;
4 | description: string;
5 | cardImage: string;
6 | }
7 |
8 | export interface Customer {
9 | id: string /* primary key */;
10 | stripe_customer_id?: string;
11 | }
12 |
13 | export interface Product {
14 | id: string /* primary key */;
15 | active?: boolean;
16 | name?: string;
17 | description?: string;
18 | image?: string;
19 | metadata?: Stripe.Metadata;
20 | }
21 |
22 | export interface ProductWithPrice extends Product {
23 | prices?: Price[];
24 | }
25 |
26 | export interface UserDetails {
27 | id: string /* primary key */;
28 | first_name: string;
29 | last_name: string;
30 | full_name?: string;
31 | avatar_url?: string;
32 | billing_address?: Stripe.Address;
33 | payment_method?: Stripe.PaymentMethod[Stripe.PaymentMethod.Type];
34 | }
35 |
36 | export interface Price {
37 | id: string /* primary key */;
38 | product_id?: string /* foreign key to products.id */;
39 | active?: boolean;
40 | description?: string;
41 | unit_amount?: number;
42 | currency?: string;
43 | type?: Stripe.Price.Type;
44 | interval?: Stripe.Price.Recurring.Interval;
45 | interval_count?: number;
46 | trial_period_days?: number | null;
47 | metadata?: Stripe.Metadata;
48 | products?: Product;
49 | }
50 |
51 | export interface PriceWithProduct extends Price {}
52 |
53 | export interface Subscription {
54 | id: string /* primary key */;
55 | user_id: string;
56 | status?: Stripe.Subscription.Status;
57 | metadata?: Stripe.Metadata;
58 | price_id?: string /* foreign key to prices.id */;
59 | quantity?: number;
60 | cancel_at_period_end?: boolean;
61 | created: string;
62 | current_period_start: string;
63 | current_period_end: string;
64 | ended_at?: string;
65 | cancel_at?: string;
66 | canceled_at?: string;
67 | trial_start?: string;
68 | trial_end?: string;
69 | prices?: Price;
70 | }
71 |
--------------------------------------------------------------------------------
/components/ui/Navbar/Navbar.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import s from './Navbar.module.css';
3 |
4 | import Logo from 'components/icons/Logo';
5 | import { useRouter } from 'next/router';
6 | import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
7 |
8 | const Navbar = () => {
9 | const router = useRouter();
10 |
11 | const { isAuthenticated } = useAuthenticationStatus();
12 | const nhost = useNhostClient();
13 |
14 | return (
15 |
57 | );
58 | };
59 |
60 | export default Navbar;
61 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/public/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/functions/webhooks/stripe.ts:
--------------------------------------------------------------------------------
1 | import { stripe } from '../_utils/stripe';
2 | import { Request, Response } from 'express';
3 | import { sdk } from 'functions/_utils/graphql-client';
4 |
5 | type NhostRequest = Request & {
6 | rawBody: string;
7 | };
8 |
9 | export default async function handler(req: NhostRequest, res: Response) {
10 | const sig = req.headers['stripe-signature'] as string;
11 | const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET as string;
12 |
13 | let event;
14 |
15 | try {
16 | event = stripe.webhooks.constructEvent(req.rawBody, sig, endpointSecret);
17 | } catch (err: any) {
18 | console.log(`⚠️ Webhook signature verification failed.`);
19 | console.log(err);
20 | return res.status(400).send(`Webhook Error: ${err.message}`);
21 | }
22 |
23 | if (!event) {
24 | console.log('no event found');
25 | console.log(event);
26 | return res.status(400).send('No event');
27 | }
28 |
29 | // Handle the event
30 | switch (event.type) {
31 | case 'checkout.session.completed': {
32 | console.log('checkout session completed!');
33 | break;
34 | }
35 | case 'customer.subscription.created': {
36 | const { object } = event.data as any;
37 |
38 | // const subscriptionId = object.id;
39 | const customerId = object.customer;
40 | const priceId = object.items.data[0].price.id;
41 |
42 | // get plan id from priceId
43 | const { plans } = await sdk.getPlans({
44 | where: {
45 | stripePriceId: {
46 | _eq: priceId
47 | }
48 | }
49 | });
50 |
51 | const plan = plans[0];
52 |
53 | if (!plan) {
54 | console.log('no plan found');
55 | return res.status(400).send('No plan found');
56 | }
57 |
58 | // update profile with new stripe subscription id
59 | await sdk.UpdateProfileUsingStripeCustomerId({
60 | stripeCustomerId: customerId,
61 | profile: {
62 | plan_id: plan.id
63 | }
64 | });
65 |
66 | break;
67 | }
68 | case 'customer.subscription.deleted': {
69 | const { object } = event.data as any;
70 | console.log('customer.subscription.deleted');
71 |
72 | const customerId = object.customer;
73 | const priceId = object.items.data[0].price.id;
74 |
75 | // remove plan_id for profile
76 | await sdk.UpdateProfileUsingStripeCustomerId({
77 | stripeCustomerId: customerId,
78 | profile: {
79 | plan_id: null
80 | }
81 | });
82 | break;
83 | }
84 | default:
85 | console.log(`Unhandled event type ${event.type}`);
86 | }
87 |
88 | res.json({ received: true });
89 | }
90 |
--------------------------------------------------------------------------------
/functions/custom/create-checkout-session.ts:
--------------------------------------------------------------------------------
1 | import { getURL } from 'utils/helpers';
2 | import { Request, Response } from 'express';
3 | import { sdk } from 'functions/_utils/graphql-client';
4 | import { allowCors, getUser } from '../_utils/helpers';
5 | import { stripe } from 'functions/_utils/stripe';
6 |
7 | const handler = async (req: Request, res: Response) => {
8 | console.log('create-checkout-session called');
9 | console.log(req.body);
10 | console.log(req.query);
11 | console.log(req.headers);
12 |
13 | // CORS
14 | res.setHeader('Access-Control-Allow-Credentials', 'true');
15 | res.setHeader('Access-Control-Allow-Origin', '*');
16 | res.setHeader('Access-Control-Allow-Headers', '*');
17 | res.setHeader('Access-Control-Allow-Methods', '*');
18 |
19 | // handle CORS
20 | if (req.method === 'OPTIONS') {
21 | console.log('request is OPTIONS, allow all');
22 | return res.status(204).send();
23 | }
24 |
25 | if (req.method !== 'POST') {
26 | res.setHeader('Allow', 'POST');
27 | return res.status(405).send('Method Not Allowed');
28 | }
29 |
30 | const authenticatedUser = getUser(req);
31 |
32 | if (!authenticatedUser) {
33 | return res.status(401).json({ error: 'Unauthorized 1' });
34 | }
35 |
36 | const { priceId } = req.body as any;
37 |
38 | try {
39 | const { user } = await sdk.getUser({ id: authenticatedUser.id });
40 |
41 | // make sure that users can only have one subscription at a time.
42 | if (user?.profile?.stripeCustomer.subscriptions.data.length) {
43 | return res
44 | .status(400)
45 | .json({ error: 'User already have a subscription' });
46 | }
47 |
48 | if (!user?.profile?.stripeCustomerId) {
49 | return res
50 | .status(400)
51 | .json({ error: 'User does not have a customer id' });
52 | }
53 |
54 | const session = await stripe.checkout.sessions.create({
55 | payment_method_types: ['card'],
56 | billing_address_collection: 'required',
57 | customer: user.profile.stripeCustomerId,
58 | line_items: [
59 | {
60 | price: priceId,
61 | quantity: 1
62 | }
63 | ],
64 | mode: 'subscription',
65 | allow_promotion_codes: true,
66 | subscription_data: {
67 | trial_from_plan: true
68 | },
69 | success_url: `https://nextjs-stripe-starter-template.vercel.app/account`,
70 | cancel_url: `https://nextjs-stripe-starter-template.vercel.app/`
71 | });
72 |
73 | return res
74 | .status(200)
75 | .json({ sessionId: session.id, sessionUrl: session.url });
76 | } catch (err: any) {
77 | console.log(err);
78 | res.status(500).json({ error: { statusCode: 500, message: err.message } });
79 | }
80 | };
81 |
82 | export default handler;
83 |
--------------------------------------------------------------------------------
/pages/signin.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useState, FormEvent } from 'react';
3 |
4 | import Button from 'components/ui/Button';
5 | import GitHub from 'components/icons/GitHub';
6 | import Input from 'components/ui/Input';
7 | import Logo from 'components/icons/Logo';
8 | import { getURL } from '@/utils/helpers';
9 | import { useSignInEmailPasswordless } from '@nhost/nextjs';
10 | import { nhost } from '@/utils/nhost';
11 |
12 | const SignIn = () => {
13 | const [email, setEmail] = useState('');
14 |
15 | const { signInEmailPasswordless, isLoading, isError, error, isSuccess } =
16 | useSignInEmailPasswordless();
17 |
18 | const handleSignin = async (e: FormEvent) => {
19 | e.preventDefault();
20 |
21 | await signInEmailPasswordless(email, {
22 | redirectTo: getURL()
23 | });
24 | };
25 |
26 | return (
27 |
28 |
29 |
30 |
31 |
32 | {isError && (
33 |
34 | {error?.message}
35 |
36 | )}
37 | {isSuccess && (
38 |
39 | Check your email for the Magic Link
40 |
41 | )}
42 |
43 |
60 |
61 |
62 |
73 |
74 |
83 |
84 |
85 | );
86 | };
87 |
88 | export default SignIn;
89 |
--------------------------------------------------------------------------------
/public/nextjs.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/nhost/config.yaml:
--------------------------------------------------------------------------------
1 | metadata_directory: metadata
2 | services:
3 | hasura:
4 | environment:
5 | hasura_graphql_enable_remote_schema_permissions: true
6 | minio:
7 | environment:
8 | minio_root_password: minioaccesskey123123
9 | minio_root_user: minioaccesskey123123
10 | postgres:
11 | environment:
12 | postgres_password: postgres
13 | postgres_user: postgres
14 | auth:
15 | access_control:
16 | email:
17 | allowed_email_domains: ''
18 | allowed_emails: ''
19 | blocked_email_domains: ''
20 | blocked_emails: ''
21 | url:
22 | allowed_redirect_urls: ''
23 | anonymous_users_enabled: false
24 | client_url: http://localhost:3000
25 | disable_new_users: false
26 | email:
27 | enabled: false
28 | passwordless:
29 | enabled: true
30 | signin_email_verified_required: true
31 | template_fetch_url: ''
32 | gravatar:
33 | default: ''
34 | enabled: true
35 | rating: ''
36 | locale:
37 | allowed: en
38 | default: en
39 | password:
40 | hibp_enabled: false
41 | min_length: 3
42 | provider:
43 | apple:
44 | client_id: ''
45 | enabled: false
46 | key_id: ''
47 | private_key: ''
48 | scope: name,email
49 | team_id: ''
50 | bitbucket:
51 | client_id: ''
52 | client_secret: ''
53 | enabled: false
54 | facebook:
55 | client_id: ''
56 | client_secret: ''
57 | enabled: false
58 | scope: email,photos,displayName
59 | github:
60 | client_id: ''
61 | client_secret: ''
62 | enabled: false
63 | scope: user:email
64 | token_url: ''
65 | user_profile_url: ''
66 | gitlab:
67 | base_url: ''
68 | client_id: ''
69 | client_secret: ''
70 | enabled: false
71 | scope: read_user
72 | google:
73 | client_id: ''
74 | client_secret: ''
75 | enabled: false
76 | scope: email,profile
77 | linkedin:
78 | client_id: ''
79 | client_secret: ''
80 | enabled: false
81 | scope: r_emailaddress,r_liteprofile
82 | spotify:
83 | client_id: ''
84 | client_secret: ''
85 | enabled: false
86 | scope: user-read-email,user-read-private
87 | strava:
88 | client_id: ''
89 | client_secret: ''
90 | enabled: false
91 | twilio:
92 | account_sid: ''
93 | auth_token: ''
94 | enabled: false
95 | messaging_service_id: ''
96 | twitter:
97 | consumer_key: ''
98 | consumer_secret: ''
99 | enabled: false
100 | windows_live:
101 | client_id: ''
102 | client_secret: ''
103 | enabled: false
104 | scope: wl.basic,wl.emails,wl.contacts_emails
105 | sms:
106 | enabled: false
107 | passwordless:
108 | enabled: false
109 | provider:
110 | twilio:
111 | account_sid: ''
112 | auth_token: ''
113 | from: ''
114 | messaging_service_id: ''
115 | smtp:
116 | host: mailhog
117 | method: ''
118 | pass: password
119 | port: 1025
120 | secure: false
121 | sender: hasura-auth@example.com
122 | user: user
123 | token:
124 | access:
125 | expires_in: 900
126 | refresh:
127 | expires_in: 43200
128 | user:
129 | allowed_roles: user,me
130 | default_allowed_roles: user,me
131 | default_role: user
132 | mfa:
133 | enabled: false
134 | issuer: nhost
135 | storage:
136 | force_download_for_content_types: text/html,application/javascript
137 | version: 3
138 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js Stripe SaaS Starter
2 |
3 | The ultimate starter kit for high-performance SaaS applications.
4 |
5 | ## Features
6 |
7 | - Secure user management and authentication with [Nhost](https://docs.nhost.io/authentication).
8 | - Powerful data access & management tooling on top of PostgreSQL with [Nhost](https://docs.nhost.io/database).
9 | - Integration with [Stripe Checkout](https://stripe.com/docs/payments/checkout) and the [Stripe customer portal](https://stripe.com/docs/billing/subscriptions/customer-portal).
10 | - Automatic syncing of pricing plans and subscription statuses via [Stripe webhooks](https://stripe.com/docs/webhooks).
11 |
12 | ## Features
13 |
14 | - Postgres Database
15 | - GraphQL API
16 | - Magic Link and GitHub Authentication
17 | - Email Templates
18 | - Remote Stripe GraphQL API
19 | - Next.js
20 | - TypeScript
21 | - Tailwind CSS
22 | - GraphQL Codegen with React Query
23 |
24 | ## Demo
25 |
26 | - [https://nextjs-stripe-starter-template.vercel.app/](https://nextjs-stripe-starter-template.vercel.app/)
27 |
28 | [](https://xxx.vercel.app/)
29 |
30 | ## Development Setup
31 |
32 | ### Nhost
33 |
34 | - Clone this repo.
35 | - Copy `.env.local.example` to `.env.development`.
36 | - Add the following environment variables from [Stripe](https://dashboard.stripe.com/test/apikeys) to the `.env.development` file:
37 | - `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
38 | - `STRIPE_SECRET_KEY`
39 |
40 | ### Stripe (test mode)
41 |
42 | - Create 4 products in the [Stripe Dashboard](https://stripe.com/docs/products-prices/getting-started).
43 | - Add the products to the `nhost/seeds/default/001-plans.sql` file. The first time you're starting the Nhost project using `nhost up` the seed script will run and add the data to your `plans` table.
44 |
45 | ### Stripe Webhooks (test mode)
46 |
47 | - Make sure you have the [Stripe CLI](https://stripe.com/docs/stripe-cli) installed.
48 | - Run `pnpm stripe:listen`.
49 | - You'll see an output stating the Webhook signing secret starting with `whsec_`.
50 | - Copy the Webhook signing secret to your `.env.development` for `STRIPE_WEBHOOK_SECRET`.
51 |
52 | ### Frontend and Backend
53 |
54 | - Start the backend with `nhost up`.
55 | - Start the frontend with `pnpm dev`.
56 |
57 | You now have a fully working backend and frontend with Next.js, Nhost, and Stripe.
58 |
59 | ## Go Live
60 |
61 | ### Nhost
62 |
63 | - Add the following [environment variables](https://docs.nhost.io/platform/environment-variables) from [Stripe](https://stripe.com/docs/keys#test-live-modes) (using live mode):
64 | - `STRIPE_SECRET_KEY`
65 | - `STRIPE_WEBHOOK_SECRET`
66 | - Connect your Nhost project to your [Git repository](https://docs.nhost.io/platform/git).
67 | - This will create the tables automatically for you.
68 |
69 | ### Stripe (live mode)
70 |
71 | - Create 4 products in the [Stripe Dashboard](https://stripe.com/docs/products-prices/getting-started) (live mode).
72 | - Add the products to the `plans` Plans database in the Nhost Dashboard.
73 |
74 | ### Stripe Webhooks (live mode)
75 |
76 | - Add a [webhook endpoint in Stripe](https://dashboard.stripe.com/webhooks) pointing to `https://{subdomain}.functions.{region}.nhost.run/v1/webhook/stripe`.
77 | - Configure the events you want to listen to. It's OK to listen to all events.
78 | - Copy the "Signing secret" and add it as an [environment variable](https://docs.nhost.io/platform/environment-variables) in Nhost with the name `STRIPE_WEBHOOK_SECRET`.
79 |
80 | ### Frontend Hosting
81 |
82 | You can use any frontend hosting, like [Vercel](https://vercel.com/) and [Netlify](https://netlify.com/). The process is the same:
83 |
84 | - Connect the repo with a new project in the frontend hosting service.
85 | - Add the following:
86 | - `NEXT_PUBLIC_NHOST_SUBDOMAIN` (from Nhost)
87 | - `NEXT_PUBLIC_NHOST_REGION` (from Nhost)
88 | - `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` (from Stripe)
89 | - Retrigger a deployment so the newly added environment variables are applied.
90 |
--------------------------------------------------------------------------------
/public/nhost.svg:
--------------------------------------------------------------------------------
1 |
23 |
--------------------------------------------------------------------------------
/components/ui/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import s from './Footer.module.css';
3 |
4 | import Logo from 'components/icons/Logo';
5 | import GitHub from 'components/icons/GitHub';
6 |
7 | export default function Footer() {
8 | return (
9 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/nhost/metadata/databases/default/tables/auth_users.yaml:
--------------------------------------------------------------------------------
1 | table:
2 | name: users
3 | schema: auth
4 | configuration:
5 | column_config:
6 | active_mfa_type:
7 | custom_name: activeMfaType
8 | avatar_url:
9 | custom_name: avatarUrl
10 | created_at:
11 | custom_name: createdAt
12 | default_role:
13 | custom_name: defaultRole
14 | disabled:
15 | custom_name: disabled
16 | display_name:
17 | custom_name: displayName
18 | email:
19 | custom_name: email
20 | email_verified:
21 | custom_name: emailVerified
22 | id:
23 | custom_name: id
24 | is_anonymous:
25 | custom_name: isAnonymous
26 | last_seen:
27 | custom_name: lastSeen
28 | locale:
29 | custom_name: locale
30 | new_email:
31 | custom_name: newEmail
32 | otp_hash:
33 | custom_name: otpHash
34 | otp_hash_expires_at:
35 | custom_name: otpHashExpiresAt
36 | otp_method_last_used:
37 | custom_name: otpMethodLastUsed
38 | password_hash:
39 | custom_name: passwordHash
40 | phone_number:
41 | custom_name: phoneNumber
42 | phone_number_verified:
43 | custom_name: phoneNumberVerified
44 | ticket:
45 | custom_name: ticket
46 | ticket_expires_at:
47 | custom_name: ticketExpiresAt
48 | totp_secret:
49 | custom_name: totpSecret
50 | updated_at:
51 | custom_name: updatedAt
52 | webauthn_current_challenge:
53 | custom_name: currentChallenge
54 | custom_column_names:
55 | active_mfa_type: activeMfaType
56 | avatar_url: avatarUrl
57 | created_at: createdAt
58 | default_role: defaultRole
59 | disabled: disabled
60 | display_name: displayName
61 | email: email
62 | email_verified: emailVerified
63 | id: id
64 | is_anonymous: isAnonymous
65 | last_seen: lastSeen
66 | locale: locale
67 | new_email: newEmail
68 | otp_hash: otpHash
69 | otp_hash_expires_at: otpHashExpiresAt
70 | otp_method_last_used: otpMethodLastUsed
71 | password_hash: passwordHash
72 | phone_number: phoneNumber
73 | phone_number_verified: phoneNumberVerified
74 | ticket: ticket
75 | ticket_expires_at: ticketExpiresAt
76 | totp_secret: totpSecret
77 | updated_at: updatedAt
78 | webauthn_current_challenge: currentChallenge
79 | custom_name: users
80 | custom_root_fields:
81 | delete: deleteUsers
82 | delete_by_pk: deleteUser
83 | insert: insertUsers
84 | insert_one: insertUser
85 | select: users
86 | select_aggregate: usersAggregate
87 | select_by_pk: user
88 | update: updateUsers
89 | update_by_pk: updateUser
90 | object_relationships:
91 | - name: defaultRoleByRole
92 | using:
93 | foreign_key_constraint_on: default_role
94 | - name: profile
95 | using:
96 | foreign_key_constraint_on:
97 | column: id
98 | table:
99 | name: profiles
100 | schema: public
101 | array_relationships:
102 | - name: refreshTokens
103 | using:
104 | foreign_key_constraint_on:
105 | column: user_id
106 | table:
107 | name: refresh_tokens
108 | schema: auth
109 | - name: roles
110 | using:
111 | foreign_key_constraint_on:
112 | column: user_id
113 | table:
114 | name: user_roles
115 | schema: auth
116 | - name: securityKeys
117 | using:
118 | foreign_key_constraint_on:
119 | column: user_id
120 | table:
121 | name: user_security_keys
122 | schema: auth
123 | - name: userProviders
124 | using:
125 | foreign_key_constraint_on:
126 | column: user_id
127 | table:
128 | name: user_providers
129 | schema: auth
130 | select_permissions:
131 | - role: user
132 | permission:
133 | columns:
134 | - display_name
135 | - email
136 | - id
137 | filter:
138 | id:
139 | _eq: X-Hasura-User-Id
140 | event_triggers:
141 | - name: insert-user-stripe
142 | definition:
143 | enable_manual: false
144 | insert:
145 | columns: '*'
146 | retry_conf:
147 | interval_sec: 10
148 | num_retries: 0
149 | timeout_sec: 60
150 | webhook: '{{NHOST_BACKEND_URL}}/v1/functions/events/users/insert/stripe'
151 | headers:
152 | - name: nhost-webhook-secret
153 | value_from_env: NHOST_WEBHOOK_SECRET
154 | cleanup_config:
155 | batch_size: 10000
156 | clean_invocation_logs: false
157 | clear_older_than: 168
158 | paused: true
159 | schedule: 0 0 * * *
160 | timeout: 60
161 |
--------------------------------------------------------------------------------
/pages/account.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { ReactNode } from 'react';
3 |
4 | import LoadingDots from 'components/ui/LoadingDots';
5 | import Button from 'components/ui/Button';
6 | import {
7 | useCreateCustomerPortalSessionMutation,
8 | useGetUserQuery
9 | } from '@/utils/__generated__/graphql';
10 | import { useUserData } from '@nhost/nextjs';
11 | import { format, fromUnixTime } from 'date-fns';
12 |
13 | interface Props {
14 | title: string;
15 | description?: string;
16 | footer?: ReactNode;
17 | children: ReactNode;
18 | }
19 |
20 | function Card({ title, description, footer, children }: Props) {
21 | return (
22 |
23 |
24 |
{title}
25 |
{description}
26 | {children}
27 |
28 |
29 | {footer}
30 |
31 |
32 | );
33 | }
34 |
35 | export default function Account() {
36 | const userData = useUserData();
37 |
38 | const { data, isLoading } = useGetUserQuery({
39 | id: userData?.id
40 | });
41 |
42 | const {
43 | mutateAsync: createCustomerPortalSession,
44 | isLoading: createCustomerPortalSessionIsLoading
45 | } = useCreateCustomerPortalSessionMutation();
46 |
47 | const redirectToCustomerPortal = async () => {
48 | const stripeCustomerId = data?.user?.profile?.stripeCustomerId;
49 |
50 | if (!stripeCustomerId) {
51 | return alert('No stripe customer ID found');
52 | }
53 |
54 | const customerPortalSessionResponse = await createCustomerPortalSession({
55 | customerId: stripeCustomerId,
56 | returnUrl: window.location.href
57 | });
58 |
59 | window.location.href =
60 | customerPortalSessionResponse.stripe.createBillingPortalSession.url;
61 | };
62 |
63 | if (isLoading) {
64 | return (
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | if (!data) {
72 | return No data..
;
73 | }
74 |
75 | const { user } = data;
76 |
77 | // find first subscription by user
78 | const productSubscription =
79 | user?.profile?.stripeCustomer.subscriptions?.data[0]?.items.data[0];
80 |
81 | console.log(productSubscription);
82 |
83 | const subscriptionPrice =
84 | productSubscription &&
85 | new Intl.NumberFormat('en-US', {
86 | style: 'currency',
87 | currency: productSubscription.price.currency
88 | }).format(productSubscription.price.unitAmount! / 100);
89 |
90 | return (
91 |
92 |
93 |
94 |
95 | Account
96 |
97 |
98 | We partnered with Stripe for a simplified billing.
99 |
100 |
101 |
102 |
103 |
112 |
113 | Manage your subscription on Stripe.
114 |
115 |
125 |
126 | }
127 | >
128 |
129 | {isLoading ? (
130 |
131 |
132 |
133 | ) : productSubscription ? (
134 | `${subscriptionPrice}/mo`
135 | ) : (
136 |
137 |
Choose your plan
138 |
139 | )}
140 |
141 |
142 |
147 | Invoices are generated automatically and sent to your email
148 | address.
149 |
150 | }
151 | >
152 | {data.user?.profile?.stripeCustomer.invoices?.data.map((invoice) => {
153 | if (!invoice.hostedInvoiceUrl) {
154 | return;
155 | }
156 |
157 | return (
158 |
164 | );
165 | })}
166 |
167 | Please use 64 characters at maximum.}
171 | >
172 |
173 | {userData?.displayName}
174 |
175 |
176 | We will email you to verify the change.}
180 | >
181 | {userData?.email}
182 |
183 |
184 |
185 | );
186 | }
187 |
--------------------------------------------------------------------------------
/components/Pricing.tsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames';
2 | import { useRouter } from 'next/router';
3 | import { useState } from 'react';
4 |
5 | import Button from 'components/ui/Button';
6 | import {
7 | PlanFragment,
8 | UserWithSubscriptionFragment
9 | } from '@/utils/__generated__/graphql';
10 | import { nhost } from '@/utils/nhost';
11 |
12 | type PricingProps = {
13 | plans: PlanFragment[];
14 | userWithSubscription: UserWithSubscriptionFragment | null | undefined;
15 | };
16 |
17 | export default function Pricing({ plans, userWithSubscription }: PricingProps) {
18 | const router = useRouter();
19 | const [priceIdLoading, setPriceIdLoading] = useState();
20 |
21 | const handleCheckout = async (priceId: string, isSubscribed: boolean) => {
22 | setPriceIdLoading(priceId);
23 |
24 | const hasActiveSubscription =
25 | userWithSubscription?.profile?.stripeCustomer.subscriptions.data.length;
26 |
27 | if (isSubscribed) {
28 | return router.push('/account');
29 | }
30 |
31 | if (hasActiveSubscription) {
32 | setPriceIdLoading(undefined);
33 | return alert('You already have an active subscription.');
34 | }
35 |
36 | try {
37 | const res = await fetch(
38 | 'https://qaxzubvwbuhzgxswghug.functions.eu-central-1.nhost.run/v1/custom/create-checkout-session',
39 | {
40 | method: 'POST',
41 | headers: {
42 | 'Content-Type': 'application/json',
43 | Authorization: `Bearer ${nhost.auth.getAccessToken()}`
44 | },
45 | body: JSON.stringify({
46 | priceId
47 | })
48 | }
49 | );
50 |
51 | console.log('fetch res:');
52 | console.log(res);
53 | } catch (error) {
54 | console.log(error);
55 | }
56 |
57 | try {
58 | const { res, error } = await nhost.functions.call(
59 | 'custom/create-checkout-session',
60 | {
61 | priceId
62 | },
63 | {
64 | useAxios: false
65 | }
66 | );
67 |
68 | console.log(res);
69 | console.log(error);
70 |
71 | if (error) {
72 | console.log(res);
73 | console.log(error);
74 | throw Error(error.message);
75 | }
76 |
77 | window.location.href = (res as any).data.sessionUrl;
78 | } catch (error) {
79 | return alert((error as Error)?.message);
80 | } finally {
81 | setPriceIdLoading(undefined);
82 | }
83 | };
84 |
85 | if (!plans.length) {
86 | return (
87 |
88 |
89 |
90 |
91 | No subscription pricing plans found.
92 |
93 |
94 |
95 | );
96 | }
97 |
98 | if (!userWithSubscription) {
99 | return no user with subscription
;
100 | }
101 |
102 | return (
103 |
104 |
105 |
106 |
107 | Pricing Plans
108 |
109 |
110 | Start building for free, then add a site plan to go live. Account
111 | plans unlock additional features.
112 |
113 |
114 |
115 | {plans.map((plan) => {
116 | // check if the user is subscribed to this product
117 | const isSubscribed =
118 | userWithSubscription.profile?.stripeCustomer.subscriptions.data.some(
119 | (subscription) => {
120 | return subscription.items.data.some((item) => {
121 | return item.price.id === plan.stripePriceId;
122 | });
123 | }
124 | ) || false;
125 |
126 | const priceString = new Intl.NumberFormat('en-US', {
127 | style: 'currency',
128 | currency: plan.currency,
129 | minimumFractionDigits: 0
130 | }).format(plan.amount / 100);
131 | return (
132 |
141 |
142 |
143 | {plan.name}
144 |
145 |
{plan.description}
146 |
147 |
148 | {priceString}
149 |
150 |
151 | /mo
152 |
153 |
154 |
166 |
167 |
168 | );
169 | })}
170 |
171 |
172 |
173 | Brought to you by
174 |
175 |
202 |
203 |
204 |
205 | );
206 | }
207 |
--------------------------------------------------------------------------------
/nhost/metadata/remote_schemas.yaml:
--------------------------------------------------------------------------------
1 | - name: stripe
2 | definition:
3 | url: '{{NHOST_FUNCTIONS_URL}}/graphql/stripe'
4 | timeout_seconds: 60
5 | customization: {}
6 | forward_client_headers: true
7 | comment: ""
8 | permissions:
9 | - role: user
10 | definition:
11 | schema: |
12 | schema {
13 | query: Query
14 | mutation: Mutation
15 | }
16 | scalar JSON
17 | type Mutation {
18 | stripe: StripeMutations!
19 | }
20 | type Query {
21 | stripe: Stripe!
22 | }
23 | type Stripe {
24 | connectedAccount(id: String!): StripeConnectedAccount!
25 | connectedAccounts: StripeConnectedAccounts!
26 | customer(id: String!): StripeCustomer!
27 | customers(email: String, endingBefore: String, limit: Int, startingAfter: String): StripeCustomers!
28 | }
29 | type StripeAddress {
30 | city: String
31 | country: String
32 | line1: String
33 | line2: String
34 | postalCode: String
35 | state: String
36 | }
37 | type StripeBillingPortalSession {
38 | created: Int!
39 | id: String!
40 | livemode: Boolean!
41 | locale: String
42 | object: String!
43 | returnUrl: String
44 | url: String!
45 | }
46 | type StripeCharge {
47 | amount: Int!
48 | amountCaptured: Int!
49 | amountRefunded: Int!
50 | application: StripeConnectedAccount
51 | applicationFeeAmount: Int
52 | billingDetails: JSON
53 | calculatedStatementDescriptor: String
54 | captured: Boolean!
55 | created: Int
56 | currency: String!
57 | customer: String!
58 | description: String
59 | disputed: Boolean!
60 | failureCode: String
61 | fraudDetails: JSON
62 | id: String!
63 | invoice: StripeInvoice
64 | livemode: Boolean!
65 | metadata: JSON
66 | outcome: JSON
67 | paid: Boolean!
68 | paymentIntent: String
69 | paymentMethod: String
70 | paymentMethodDetails: JSON
71 | receiptEmail: String
72 | receiptNumber: String
73 | receiptUrl: String
74 | refunded: Boolean!
75 | refunds: JSON
76 | shipping: JSON
77 | statementDescriptor: String
78 | statementDescriptorSuffix: String
79 | status: String!
80 | transferData: JSON
81 | transferGroup: String
82 | }
83 | type StripeCharges {
84 | data: [StripeCharge!]!
85 | hasMore: Boolean!
86 | object: String!
87 | url: String!
88 | }
89 | type StripeConnectedAccount {
90 | businessProfile: JSON!
91 | businessType: String
92 | capabilities: JSON!
93 | chargesEnabled: Boolean!
94 | company: JSON!
95 | controller: JSON
96 | country: String
97 | created: Int
98 | defaultCurrency: String
99 | detailsSubmitted: Boolean!
100 | email: String
101 | externalAccounts: JSON!
102 | futureRequirements: JSON!
103 | id: String!
104 | individual: JSON!
105 | metadata: JSON!
106 | object: String!
107 | payoutsEnabled: Boolean!
108 | requirements: JSON!
109 | settings: JSON!
110 | tosAcceptance: JSON!
111 | }
112 | type StripeConnectedAccounts {
113 | data: [StripeConnectedAccount!]!
114 | hasMore: Boolean!
115 | object: String!
116 | url: String!
117 | }
118 | type StripeCustomer {
119 | address: StripeAddress
120 | balance: Int!
121 | charges: StripeCharges!
122 | created: Int!
123 | currency: String
124 | delinquent: Boolean
125 | description: String
126 | email: String
127 | id: String!
128 | invoicePrefix: String
129 | invoices: StripeInvoices!
130 | livemode: Boolean!
131 | metadata: JSON!
132 | name: String
133 | nextInvoiceSequence: Int
134 | object: String!
135 | paymentIntents: StripePaymentIntents!
136 | paymentMethods(endingBefore: String, limit: Int, startingAfter: String, type: StripePaymentMethodTypes! = card): StripePaymentMethods!
137 | phone: String
138 | preferredLocales: [String!]
139 | shipping: StripeCustomerShipping
140 | subscriptions: StripeSubscriptions!
141 | tax: StripeCustomerTax
142 | }
143 | type StripeCustomerShipping {
144 | address: StripeAddress
145 | carrier: String
146 | name: String
147 | phone: String
148 | trackingNumber: String
149 | }
150 | type StripeCustomerTax {
151 | ipAddress: String
152 | location: StripeCustomerTaxLocation
153 | }
154 | type StripeCustomerTaxLocation {
155 | country: String!
156 | state: String
157 | }
158 | type StripeCustomers {
159 | data: [StripeCustomer!]!
160 | hasMore: Boolean!
161 | object: String!
162 | url: String!
163 | }
164 | type StripeInvoice {
165 | accountCountry: String
166 | accountName: String
167 | amountDue: Int!
168 | amountPaid: Int!
169 | amountRemaining: Int!
170 | application: StripeConnectedAccount
171 | applicationFeeAmount: Int
172 | attemptCount: Int!
173 | attempted: Boolean!
174 | autoAdvance: Boolean
175 | automaticTax: StripeInvoiceAutomaticTax!
176 | billingReason: String
177 | collectionMethod: String
178 | created: Int!
179 | currency: String!
180 | customer: String!
181 | customerAddress: StripeAddress
182 | customerEmail: String
183 | customerName: String
184 | customerPhone: String
185 | customerShipping: StripeInvoiceCustomerShipping
186 | customerTaxExempt: String
187 | customerTaxIds: [StripeInvoiceCustomerTaxId!]
188 | defaultPaymentMethod: StripePaymentMethod
189 | description: String
190 | dueDate: Int
191 | endingBalance: Int
192 | footer: String
193 | hostedInvoiceUrl: String
194 | id: String!
195 | invoicePdf: String
196 | lines: StripeInvoiceLineItems!
197 | livemode: Boolean!
198 | metadata: JSON!
199 | nextPaymentAttempt: Int
200 | number: String
201 | object: String!
202 | paid: Boolean!
203 | paidOutOfBand: Boolean!
204 | periodEnd: Int!
205 | periodStart: Int!
206 | postPaymentCreditNotesAmount: Int!
207 | prePaymentCreditNotesAmount: Int!
208 | receiptNumber: String
209 | renderingOptions: StripeInvoiceRenderingOptions
210 | startingBalance: Int!
211 | statementDescriptor: String
212 | status: String
213 | statusTransition: StripeInvoiceStatusTransitions
214 | subscription: StripeSubscription
215 | subscriptionProrationDate: Int
216 | subtotal: Int!
217 | subtotalExcludingTax: Int
218 | tax: Int
219 | total: Int!
220 | totalExcludingTax: Int
221 | webhooksDeliveredAt: Int
222 | }
223 | type StripeInvoiceAutomaticTax {
224 | enabled: Boolean!
225 | status: String
226 | }
227 | type StripeInvoiceCustomerShipping {
228 | address: StripeAddress
229 | carrier: String
230 | name: String
231 | phone: String
232 | trackingNumber: String
233 | }
234 | type StripeInvoiceCustomerTaxId {
235 | type: String!
236 | value: String
237 | }
238 | type StripeInvoiceLineItem {
239 | amount: Int!
240 | amountExcludingTax: Int
241 | currency: String!
242 | description: String
243 | discountable: Boolean!
244 | id: String!
245 | invoiceItem: String
246 | livemode: Boolean!
247 | metadata: JSON!
248 | object: String!
249 | period: StripeInvoiceLineItemPeriod!
250 | plan: StripePlan
251 | price: StripePrice
252 | proration: Boolean!
253 | quantity: Int
254 | taxAmount: [StripeInvoiceLineItemTaxAmount!]
255 | taxRates: [StripeTaxRate!]
256 | type: String!
257 | unitAmountExcludingTax: String
258 | }
259 | type StripeInvoiceLineItemPeriod {
260 | end: Int!
261 | start: Int!
262 | }
263 | type StripeInvoiceLineItemTaxAmount {
264 | amount: Int!
265 | inclusive: Boolean!
266 | }
267 | type StripeInvoiceLineItems {
268 | data: [StripeInvoiceLineItem!]!
269 | hasMore: Boolean!
270 | object: String!
271 | url: String!
272 | }
273 | type StripeInvoiceRenderingOptions {
274 | amountTaxDisplay: String
275 | }
276 | type StripeInvoiceStatusTransitions {
277 | finalizedAt: Int
278 | markedUncollectibleAt: Int
279 | paidAt: Int
280 | voidedAt: Int
281 | }
282 | type StripeInvoices {
283 | data: [StripeInvoice!]!
284 | hasMore: Boolean!
285 | object: String!
286 | url: String!
287 | }
288 | type StripeMutations {
289 | createBillingPortalSession(configuration: String, customer: String!, locale: String, returnUrl: String): StripeBillingPortalSession!
290 | }
291 | type StripePaymentIntent {
292 | amount: Int!
293 | amountCapturable: Int!
294 | amountDetails: JSON
295 | amountReceived: Int!
296 | applicationFeeAmount: Int
297 | canceledAt: Int
298 | cancellationReason: String
299 | created: Int
300 | currency: String!
301 | customer: String!
302 | description: String
303 | id: String!
304 | invoice: StripeInvoice
305 | metadata: JSON
306 | object: String!
307 | paymentMethodTypes: [String!]!
308 | receiptEmail: String
309 | statementDescriptor: String
310 | statementDescriptorSuffix: String
311 | status: String!
312 | transferGroup: String
313 | }
314 | type StripePaymentIntents {
315 | data: [StripePaymentIntent!]!
316 | hasMore: Boolean!
317 | object: String!
318 | url: String!
319 | }
320 | type StripePaymentMethod {
321 | billingDetails: StripePaymentMethodBillingDetails
322 | card: StripePaymentMethodCard
323 | created: Int!
324 | customer: String
325 | id: String!
326 | livemode: Boolean!
327 | metadata: JSON!
328 | object: String!
329 | type: StripePaymentMethodTypes!
330 | }
331 | type StripePaymentMethodBillingDetails {
332 | address: StripeAddress
333 | email: String
334 | name: String
335 | phone: String
336 | }
337 | type StripePaymentMethodCard {
338 | brand: String!
339 | check: StripePaymentMethodCardChecks
340 | country: String
341 | description: String
342 | expMonth: Int!
343 | expYear: Int!
344 | fingerprint: String
345 | funding: String!
346 | iin: String
347 | issuer: String
348 | last4: String!
349 | networks: StripePaymentMethodCardNetworks
350 | threeDSecureUsage: StripePaymentMethodCardThreeDSecureUsage
351 | wallet: StripePaymentMethodCardWallet
352 | }
353 | type StripePaymentMethodCardChecks {
354 | addressLine1Check: String
355 | addressPostalCodeCheck: String
356 | cvcCheck: String
357 | }
358 | type StripePaymentMethodCardNetworks {
359 | available: [String!]!
360 | preferred: String
361 | }
362 | type StripePaymentMethodCardThreeDSecureUsage {
363 | supported: Boolean!
364 | }
365 | type StripePaymentMethodCardWallet {
366 | dynamicLast4: String
367 | masterpass: StripePaymentMethodCardWalletMasterpass
368 | type: StripePaymentMethodCardWalletType!
369 | visaCheckout: StripePaymentMethodCardWalletVisaCheckout
370 | }
371 | type StripePaymentMethodCardWalletMasterpass {
372 | billingAddress: StripeAddress
373 | email: String
374 | name: String
375 | shippinAddress: StripeAddress
376 | }
377 | type StripePaymentMethodCardWalletVisaCheckout {
378 | billingAddress: StripeAddress
379 | email: String
380 | name: String
381 | shippinAddress: StripeAddress
382 | }
383 | type StripePaymentMethods {
384 | data: [StripePaymentMethod!]!
385 | hasMore: Boolean!
386 | object: String!
387 | url: String!
388 | }
389 | type StripePlan {
390 | active: Boolean!
391 | aggregateUsage: String
392 | amount: Int
393 | amountDecimal: String
394 | billingScheme: String!
395 | created: Int!
396 | currency: String!
397 | id: String!
398 | interval: String!
399 | intervalCount: Int!
400 | livemode: Boolean!
401 | metadata: JSON
402 | nickname: String
403 | object: String!
404 | product: StripeProduct
405 | tiersMode: String
406 | transformUsage: StripePlanTransformUsage
407 | trialPeriodDays: Int
408 | usageType: String!
409 | }
410 | type StripePlanTransformUsage {
411 | divideBy: Int!
412 | round: String!
413 | }
414 | type StripePrice {
415 | active: Boolean!
416 | billingScheme: String!
417 | created: Int!
418 | currency: String!
419 | id: String!
420 | livemode: Boolean!
421 | lookupKey: String
422 | metadata: JSON
423 | nickname: String
424 | object: String!
425 | product: StripeProduct!
426 | tiersMode: String
427 | type: String!
428 | unitAmount: Int
429 | unitAmountDecimal: String
430 | }
431 | type StripeProduct {
432 | active: Boolean!
433 | attributes: [String!]
434 | caption: String
435 | created: Int!
436 | deactivateOn: [String!]
437 | defaultPrice: StripePrice
438 | description: String
439 | id: String!
440 | images: [String!]
441 | livemode: Boolean!
442 | metadata: JSON
443 | name: String!
444 | object: String!
445 | sippable: Boolean
446 | statementDescriptor: String
447 | type: String!
448 | unitLabel: String
449 | updated: Int!
450 | url: String
451 | }
452 | type StripeSubscription {
453 | applicationFeePercent: Float
454 | automaticTax: StripeSubscriptionAutomaticTax!
455 | billingCycleAnchor: Int!
456 | billingThresholds: StripeSubscriptionBillingThresholds
457 | cancelAt: Int
458 | cancelAtPeriodEnd: Boolean!
459 | canceledAt: Int
460 | collectionMethods: String!
461 | created: Int!
462 | currency: String!
463 | currentPeriodEnd: Int!
464 | currentPeriodStart: Int!
465 | customer: String!
466 | daysUntilDue: Int
467 | defaultPaymentMethod: StripePaymentMethod
468 | defaultTaxRates: [StripeTaxRate!]
469 | description: String
470 | endedAt: Int
471 | id: String!
472 | items: StripeSubscriptionItems!
473 | latestInvoice: StripeInvoice
474 | livemode: Boolean!
475 | metadata: JSON!
476 | nextPendingInvoiceItemInvoice: Int
477 | object: String!
478 | pauseCollection: StripeSubscriptionPauseCollection
479 | startDate: Int!
480 | status: String!
481 | testClock: StripeTestClock
482 | trialEnd: Int
483 | trialStart: Int
484 | }
485 | type StripeSubscriptionAutomaticTax {
486 | enabled: Boolean!
487 | }
488 | type StripeSubscriptionBillingThresholds {
489 | amountGte: Int
490 | resetBillingCycleAnchor: Boolean
491 | }
492 | type StripeSubscriptionItem {
493 | billingThresholds: StripeSubscriptionItemBillingThresholds
494 | created: Int!
495 | id: String!
496 | metadata: JSON!
497 | object: String!
498 | plan: StripePlan!
499 | price: StripePrice!
500 | quantity: Int
501 | subscription: String!
502 | }
503 | type StripeSubscriptionItemBillingThresholds {
504 | usageGte: Int
505 | }
506 | type StripeSubscriptionItems {
507 | data: [StripeSubscriptionItem!]!
508 | hasMore: Boolean!
509 | object: String!
510 | url: String!
511 | }
512 | type StripeSubscriptionPauseCollection {
513 | behavior: String!
514 | resumesAt: Int
515 | }
516 | type StripeSubscriptions {
517 | data: [StripeSubscription!]!
518 | hasMore: Boolean!
519 | object: String!
520 | url: String!
521 | }
522 | type StripeTaxRate {
523 | active: Boolean!
524 | country: String
525 | created: Int!
526 | description: String
527 | displayName: String!
528 | id: String!
529 | inclusive: Boolean!
530 | jurisdiction: String
531 | livemode: Boolean!
532 | metadata: JSON
533 | object: String!
534 | percentage: Float!
535 | state: String
536 | taxType: String
537 | }
538 | type StripeTestClock {
539 | created: Int!
540 | deletesAfter: Int!
541 | frozenTime: Int!
542 | id: String!
543 | livemode: Boolean!
544 | name: String
545 | object: String!
546 | status: String!
547 | }
548 | enum StripePaymentMethodCardWalletType {
549 | amex_express_checkout
550 | apple_pay
551 | google_pay
552 | masterpass
553 | samsung_pay
554 | visa_checkout
555 | }
556 | enum StripePaymentMethodTypes {
557 | acss_debit
558 | affirm
559 | afterpay_clearpay
560 | alipay
561 | au_becs_debit
562 | bacs_debit
563 | bancontact
564 | blik
565 | boleto
566 | card
567 | card_present
568 | customer_balance
569 | eps
570 | fpx
571 | giropay
572 | grabpay
573 | ideal
574 | klarna
575 | konbini
576 | link
577 | oxxo
578 | p24
579 | paynow
580 | promptpay
581 | sepa_debit
582 | sofort
583 | us_bank_account
584 | wechat_pay
585 | }
586 |
--------------------------------------------------------------------------------