Skip to content

Commit

Permalink
feat: added working billing with stripe
Browse files Browse the repository at this point in the history
Took 5 hours 10 minutes
  • Loading branch information
rokartur committed Jun 21, 2024
1 parent 261b474 commit a8193fb
Show file tree
Hide file tree
Showing 18 changed files with 408 additions and 17 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ jobs:
port: ${{ secrets.SSH_SERVER_PORT }}
script: |
cd ${{ secrets.SSH_SERVER_BACKEND_PATH }}
echo -e 'OAUTH_CLIENT_ID="${{ secrets.OAUTH_CLIENT_ID }}"\nOAUTH_CLIENT_SECRET="${{ secrets.OAUTH_CLIENT_SECRET }}"\nOAUTH_REDIRECT_URI="${{ secrets.OAUTH_REDIRECT_URI }}"\nDB_URL="${{ secrets.DB_URL }}"' > .env
echo -e 'OAUTH_CLIENT_ID="${{ secrets.OAUTH_CLIENT_ID }}"\nOAUTH_CLIENT_SECRET="${{ secrets.OAUTH_CLIENT_SECRET }}"\nOAUTH_REDIRECT_URI="${{ secrets.OAUTH_REDIRECT_URI }}"\nDB_URL="${{ secrets.DB_URL }}"\nSTRIPE_PUBLISHABLE_KEY="${{ secrets.STRIPE_PUBLISHABLE_KEY }}"\nSTRIPE_SECRET_KEY="${{ secrets.STRIPE_SECRET_KEY }}"\nSTRIPE_SUCCESS_KEY="${{ secrets.STRIPE_SUCCESS_KEY }}"' > .env
- name: Run backend
uses: appleboy/[email protected]
Expand Down
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ FitPlan Connect is a scheduling app that has earned recognition as one of the be

## Features

- Fast create account and login with GitHub OAuth
- Planning and tracking meetings with personal trainers
- Choose your personal trainer
- Preview your plan along with your subscription end date
- [x] Fast create account and login with GitHub OAuth
- [x] Planning and tracking meetings with personal trainers
- [ ] Choose your personal trainer
- [x] Preview your plan along with your subscription end date

## Installation

Expand All @@ -34,14 +34,17 @@ FitPlan Connect is a scheduling app that has earned recognition as one of the be
```bash
cd backend
bun install
bun run --watch src/app.ts
bun run dev
```
8. Environment file should be created in the root of the backend folder with the following content:
```dotenv
OAUTH_CLIENT_ID=""
OAUTH_CLIENT_SECRET=""
OAUTH_REDIRECT_URI="http://localhost/api/oauth/callback"
DB_URL="postgresql://user:password@host:port/database"
STRIPE_PUBLISHABLE_KEY=""
STRIPE_SECRET_KEY=""
STRIPE_SUCCESS_KEY=""
```
9. Setup database with the following command:
```bash
Expand All @@ -67,6 +70,9 @@ FitPlan Connect is a scheduling app that has earned recognition as one of the be
- [Nginx](https://nginx.org/en/)
- [GitHub OAuth](https://docs.github.com/en/apps)

## 🇵🇱 Summary


## License
[“Commons Clause” License Condition v1.0](https://github.com/rokartur/fitplanconnect/?tab=License-1-ov-file)

Expand Down
Binary file modified backend/bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@neondatabase/serverless": "^0.9.1",
"@paralleldrive/cuid2": "^2.2.2",
"arctic": "^1.8.0",
"dayjs": "^1.11.11",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.30.9",
"drizzle-typebox": "^0.1.1",
Expand All @@ -29,6 +30,7 @@
"pg": "^8.11.5",
"postgres": "^3.4.4",
"react": "^18.3.1",
"stripe": "^15.12.0",
"studio": "^0.13.5",
"uuid": "^9.0.1"
},
Expand Down
119 changes: 119 additions & 0 deletions backend/src/routes/billing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { ElysiaApp } from '@/app'
import { db } from '@/utils/db'
import { eq } from 'drizzle-orm'
import { sessions, users } from '@/db/schema'
import { validateRequest } from '@/utils/validateRequest'
import Stripe from 'stripe'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { t } from 'elysia'

dayjs.extend(utc)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)

export default (app: ElysiaApp) =>
app
.get('/config', async ({ set, cookie: { auth_session } }) => {
const userSession = await db.query.sessions.findFirst({ where: eq(sessions.id, auth_session.value) })
const { user, session } = await validateRequest(auth_session)

if (user && session && userSession) {
const userData = await db.query.users.findFirst({ where: eq(users.id, userSession.userId) })

if (userData?.accessToken) {
set.status = 200
return {
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY as string,
}
}

if (!userData) {
set.status = 401
return { message: 'unauthorized' }
}
} else {
set.status = 401
return { message: 'unauthorized' }
}
})
.post('/', async ({ set, cookie: { auth_session } }) => {
const userSession = await db.query.sessions.findFirst({ where: eq(sessions.id, auth_session.value) })
const { user, session } = await validateRequest(auth_session)

if (user && session && userSession) {
const userData = await db.query.users.findFirst({ where: eq(users.id, userSession.userId) })

if (userData?.accessToken) {
try {
const paymentIntent: Stripe.PaymentIntent = await stripe.paymentIntents.create({
currency: 'usd',
amount: 199,
})
set.status = 200
return { clientSecret: paymentIntent.client_secret }
} catch (error) {
set.status = 403
return error
}
}

if (!userData) {
set.status = 401
return { message: 'unauthorized' }
}
} else {
set.status = 401
return { message: 'unauthorized' }
}
})
.get('/success', async ({ set, query: { key }, cookie: { auth_session } }) => {
const userSession = await db.query.sessions.findFirst({ where: eq(sessions.id, auth_session.value) })
const { user, session } = await validateRequest(auth_session)

console.log(123, key)

if (user && session && userSession) {
const userData = await db.query.users.findFirst({ where: eq(users.id, userSession.userId) })

if (userData?.accessToken) {
if (key !== process.env.STRIPE_SUCCESS_KEY) {
set.status = 403
return { message: 'forbidden' }
}

const subscriptionExpirationDate = dayjs()
.utc()
.add(1, 'year')
.set('hours', 0)
.set('minutes', 0)
.set('seconds', 0)
.set('milliseconds', 0)
.toISOString()

console.log(123, userData.accessToken, subscriptionExpirationDate)

const updatedUser = await db
.update(users)
.set({ subscriptionExpirationDate })
.where(eq(users.accessToken, userData.accessToken))
.returning({ id: users.id })

console.log([updatedUser])

if (updatedUser.length === 0) {
set.status = 403
return { message: 'forbidden' }
} else {
set.status = 301
set.redirect = '/app/billing/complete'
}
}
} else {
set.status = 401
return { message: 'unauthorized' }
}
}, {
query: t.Object({
key: t.String(),
}),
})
4 changes: 1 addition & 3 deletions backend/src/routes/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ export default (app: ElysiaApp) => app
try {
const state = generateState()

const authURL = await github.createAuthorizationURL(state, {
scopes: ['user:email'],
})
const authURL = await github.createAuthorizationURL(state, { scopes: ['user:email'] })

github_oauth_state.set({
value: state,
Expand Down
2 changes: 1 addition & 1 deletion website/.million/store.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@gsap/react": "^2.1.1",
"@million/lint": "^1.0.0-rc.11",
"@reduxjs/toolkit": "^2.2.5",
"@stripe/stripe-js": "^4.0.0",
"csstype": "^3.1.3",
"gsap": "^3.12.5",
"moment": "^2.30.1",
Expand Down
8 changes: 7 additions & 1 deletion website/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '@stripe/stripe-js'
import '@/styles/global.scss'
import { lazy, memo, Suspense } from 'react'
import { useGSAP } from '@gsap/react'
Expand All @@ -15,6 +16,9 @@ gsap.registerPlugin(useGSAP, ScrollTrigger)

const Settings = lazy(() => import('@/pages/app/settings'))
const Calendar = lazy(() => import('@/pages/app/calendar'))
const Billing = lazy(() => import('@/pages/app/billing'))
const BillingComplete = lazy(() => import('@/pages/app/billing.complete'))
const BillingCancel = lazy(() => import('@/pages/app/billing.cancel'))
const NotFound = lazy(() => import('@/pages/notFound'))

const MemoizedRoutes = memo(() => (
Expand All @@ -25,7 +29,9 @@ const MemoizedRoutes = memo(() => (
<Route path={'/'} element={<h1>landing</h1>} />
<Route path={'/app/calendar'} element={<Calendar />} />
<Route path={'/app/trainers'} element={<h1>trainers</h1>} />
<Route path={'/app/billing'} element={<h1>billing</h1>} />
<Route path={'/app/billing'} element={<Billing/>} />
<Route path={'/app/billing/complete'} element={<BillingComplete/>} />
<Route path={'/app/billing/cancel'} element={<BillingCancel/>} />
<Route path={'/app/settings'} element={<Settings />} />
<Route path={'*'} element={<NotFound />} />
</Routes>
Expand Down
Binary file added website/src/assets/images/billing_background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 8 additions & 4 deletions website/src/components/alertDialog/alertDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type AlertDialogTypes = {
closeWhenClickEscape?: boolean
labelOnCancel?: string
labelOnConfirm?: string
showButtons?: boolean
}

export const AlertDialog = ({
Expand All @@ -31,6 +32,7 @@ export const AlertDialog = ({
closeWhenClickEscape = false,
labelOnCancel = 'Cancel',
labelOnConfirm = 'Confirm',
showButtons = true,
}: AlertDialogTypes) => {
const [isClose, setIsClose] = useState(false)
const body = document.querySelector<HTMLBodyElement>('body')
Expand Down Expand Up @@ -104,10 +106,12 @@ export const AlertDialog = ({
</div>
</div>

<div className={styles.alertDialogActions}>
<Button type={'tertiary'} size={'small'} label={labelOnCancel} onClick={onClose} />
<Button type={'secondary'} size={'small'} label={labelOnConfirm} onClick={onConfirm} />
</div>
{showButtons && (
<div className={styles.alertDialogActions}>
<Button type={'tertiary'} size={'small'} label={labelOnCancel} onClick={onClose} />
<Button type={'secondary'} size={'small'} label={labelOnConfirm} onClick={onConfirm} />
</div>
)}
</div>
</div>
</div>
Expand Down
33 changes: 33 additions & 0 deletions website/src/pages/app/billing.cancel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { SEO } from '@/components/seo.tsx'
import { Overlay } from '@/components/overlay/overlay.tsx'
import { AnimateWrapper } from '@/components/animateWrapper/animateWrapper.tsx'
import { Container } from '@/components/container/container.tsx'
import styles from '@/styles/notFound.module.scss'
import { Link } from 'react-router-dom'

const metaData = {
title: 'Thank you',
path: '/app/billing/complete',
}

export default function BillingCancel() {
return (
<>
<SEO title={metaData.title} path={metaData.path} />

<Overlay>
<AnimateWrapper>
<Container>
<div className={styles.notFoundMainContainer}>
<h2>Your transaction has been cancelled</h2>
<i>Please try again</i>
<Link to={'/app/billing'}>
Go to billing
</Link>
</div>
</Container>
</AnimateWrapper>
</Overlay>
</>
)
}
33 changes: 33 additions & 0 deletions website/src/pages/app/billing.complete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { SEO } from '@/components/seo.tsx'
import { Overlay } from '@/components/overlay/overlay.tsx'
import { AnimateWrapper } from '@/components/animateWrapper/animateWrapper.tsx'
import { Container } from '@/components/container/container.tsx'
import styles from '@/styles/notFound.module.scss'
import { Link } from 'react-router-dom'

const metaData = {
title: 'Thank you',
path: '/app/billing/complete',
}

export default function ThankYouPage() {
return (
<>
<SEO title={metaData.title} path={metaData.path} />

<Overlay>
<AnimateWrapper>
<Container>
<div className={styles.notFoundMainContainer}>
<h2>Thank you for your purchase</h2>
<i>Your transaction has been successfully completed</i>
<Link to={'/app/calendar'}>
Go to your calendar
</Link>
</div>
</Container>
</AnimateWrapper>
</Overlay>
</>
);
}
Loading

0 comments on commit a8193fb

Please sign in to comment.