Merge pull request #3 from StevanFreeborn/stevanfreeborn/feat/integrate-stripe
feat: integrate stripe for payments
This commit is contained in:
@@ -23,6 +23,12 @@ insert_final_newline = false
|
|||||||
#### .NET Coding Conventions ####
|
#### .NET Coding Conventions ####
|
||||||
[*.{cs,vb}]
|
[*.{cs,vb}]
|
||||||
|
|
||||||
|
# Analyzer severity levels
|
||||||
|
dotnet_diagnostic.IDE0058.severity = none
|
||||||
|
dotnet_diagnostic.IDE0100.severity = none
|
||||||
|
dotnet_diagnostic.IDE3000.severity = none
|
||||||
|
dotnet_diagnostic.CA1848.severity = none
|
||||||
|
|
||||||
# Organize usings
|
# Organize usings
|
||||||
dotnet_separate_import_directive_groups = true
|
dotnet_separate_import_directive_groups = true
|
||||||
dotnet_sort_system_directives_first = true
|
dotnet_sort_system_directives_first = true
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
- name: Build and push server image
|
- name: Build and push server image
|
||||||
run: |
|
run: |
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/groundsforsupport.stevanfreeborn.com:${{ steps.version.outputs.version }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/groundsforsupport.stevanfreeborn.com:${{ steps.version.outputs.version }}
|
||||||
docker build -t $TAG src/
|
docker build --build-arg VITE_STRIPE_API_KEY=${{ secrets.VITE_STRIPE_API_KEY }} -t $TAG src/
|
||||||
docker push $TAG
|
docker push $TAG
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to server
|
name: Deploy to server
|
||||||
@@ -52,13 +52,17 @@ jobs:
|
|||||||
script: |
|
script: |
|
||||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/groundsforsupport.stevanfreeborn.com:${{ needs.build.outputs.version }}
|
TAG=${{ secrets.DOCKERHUB_USERNAME }}/groundsforsupport.stevanfreeborn.com:${{ needs.build.outputs.version }}
|
||||||
|
|
||||||
docker stop groundsforsupport.stevanfreeborn.com
|
docker stop groundsforsupport.stevanfreeborn.com || true
|
||||||
docker rm groundsforsupport.stevanfreeborn.com
|
docker rm groundsforsupport.stevanfreeborn.com || true
|
||||||
|
|
||||||
docker pull $TAG
|
docker pull $TAG
|
||||||
docker run \
|
docker run \
|
||||||
--restart always \
|
--restart always \
|
||||||
-d \
|
-d \
|
||||||
-p 4343:8080 \
|
-p 4343:8080 \
|
||||||
|
-e StripeOptions__ApiKey=${{ secrets.STRIPE_API_KEY }} \
|
||||||
|
-e StripeOptions__EventsWebhookSecret=${{ secrets.STRIPE_EVENTS_WEBHOOK_SECRET }} \
|
||||||
|
-e ContextOptions__DatabaseFilePath=/app/data/groundsforsupport.db \
|
||||||
|
-v /home/${{ secrets.SSH_USERNAME }}/groundsforsupport.stevanfreeborn.com/data:/app/data \
|
||||||
--name groundsforsupport.stevanfreeborn.com \
|
--name groundsforsupport.stevanfreeborn.com \
|
||||||
$TAG
|
$TAG
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
appsettings*.json
|
appsettings*.json
|
||||||
!appsettings.Example.json
|
!appsettings.Example.json
|
||||||
wwwroot/
|
wwwroot/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
# dotenv files
|
# dotenv files
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GroundsForSupport.Server.Tests", "GroundsForSupport.Server.Tests", "{56565355-472A-D7A5-BBD6-5A9243A600D2}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GroundsForSupport.Server.Tests", "GroundsForSupport.Server.Tests", "{56565355-472A-D7A5-BBD6-5A9243A600D2}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GroundsForSupport.API.Tests", "tests\GroundsForSupport.Server.Tests\GroundsForSupport.API.Tests.csproj", "{73D83B11-C5BC-4C4B-82BF-2DCEE689EDDC}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GroundsForSupport.Server.Tests", "tests\GroundsForSupport.Server.Tests\GroundsForSupport.Server.Tests.csproj", "{73D83B11-C5BC-4C4B-82BF-2DCEE689EDDC}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
|||||||
@@ -1,6 +1,105 @@
|
|||||||
# Grounds for Support
|
# Grounds for Support
|
||||||
|
|
||||||
⚠️ Under Construction ⚠️
|
Grounds for Support is a full-stack web application designed to facilitate donations ("buying a coffee") using Stripe. It demonstrates a modern integration of a React frontend with an ASP.NET Core backend, handling secure payments, webhooks, and data persistence.
|
||||||
|
|
||||||
This is a simple application I built to help me better understand how to intergrate Stripe so that I can accept payments from people over the interwebz.
|
## Features
|
||||||
|
|
||||||
|
- **Secure Donations**: Integrated with Stripe Payment Intents for secure transaction processing.
|
||||||
|
- **Custom Amounts**: Users can specify their donation amount, name, and a personal message.
|
||||||
|
- **Real-time Feedback**: Immediate confirmation of payment status.
|
||||||
|
- **Recent Activity**: Displays a list of recent supporters.
|
||||||
|
- **Rate Limiting**: API endpoints are protected against abuse.
|
||||||
|
- **Responsive Design**: Works seamlessly on desktop and mobile.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- **Framework**: React 19 with TypeScript
|
||||||
|
- **Build Tool**: Vite
|
||||||
|
- **Styling**: CSS
|
||||||
|
- **Payments**: Stripe Elements (`@stripe/react-stripe-js`)
|
||||||
|
- **Testing**: Vitest, React Testing Library
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **Framework**: ASP.NET Core (Minimal APIs)
|
||||||
|
- **Language**: C#
|
||||||
|
- **Database**: SQLite with Entity Framework Core
|
||||||
|
- **Payments**: Stripe.net SDK
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
|
||||||
|
- **Containerization**: Docker
|
||||||
|
- **CI/CD**: GitHub Actions (Build & Deploy)
|
||||||
|
- **Hosting**: Linux VPS (Ubuntu)
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- .NET SDK
|
||||||
|
- Node.js
|
||||||
|
- Stripe Account (for API keys)
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/StevanFreeborn/groundsforsupport.stevanfreeborn.com.git
|
||||||
|
cd groundsforsupport.stevanfreeborn.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Environment**
|
||||||
|
- Update `src/GroundsForSupport.Server/appsettings.Development.json` (or use User Secrets) with your Stripe keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"StripeOptions": {
|
||||||
|
"ApiKey": "sk_test_...",
|
||||||
|
"EventsWebhookSecret": "whsec_..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Create a `.env` file in `src/GroundsForSupport.Client` with your publishable key:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
VITE_STRIPE_API_KEY=pk_test_...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run the Application**
|
||||||
|
The server project is configured to build the client automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/GroundsForSupport.Server
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at `https://localhost:7071` (or similar), and it will serve the static frontend files.
|
||||||
|
|
||||||
|
*Alternatively, run frontend separately for hot-reloading:*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/GroundsForSupport.Client
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Frontend Tests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/GroundsForSupport.Client
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Backend Tests**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ RUN apt-get update && \
|
|||||||
COPY ["GroundsForSupport.Server/GroundsForSupport.Server.csproj", "GroundsForSupport.Server/"]
|
COPY ["GroundsForSupport.Server/GroundsForSupport.Server.csproj", "GroundsForSupport.Server/"]
|
||||||
RUN dotnet restore "GroundsForSupport.Server/GroundsForSupport.Server.csproj"
|
RUN dotnet restore "GroundsForSupport.Server/GroundsForSupport.Server.csproj"
|
||||||
|
|
||||||
|
ARG VITE_STRIPE_API_KEY
|
||||||
|
ENV VITE_STRIPE_API_KEY=$VITE_STRIPE_API_KEY
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/GroundsForSupport.Server"
|
WORKDIR "/src/GroundsForSupport.Server"
|
||||||
RUN dotnet build "GroundsForSupport.Server.csproj" -c Release -o /app/build
|
RUN dotnet build "GroundsForSupport.Server.csproj" -c Release -o /app/build
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
NODE_ENV=MODE_PLACEHOLDER
|
||||||
|
VITE_STRIPE_API_KEY=VITE_STRIPE_API_KEY_PLACEHOLDER
|
||||||
+63
-1
@@ -8,6 +8,8 @@
|
|||||||
"name": "groundsforsupport-client",
|
"name": "groundsforsupport-client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@stripe/react-stripe-js": "^5.4.1",
|
||||||
|
"@stripe/stripe-js": "^8.6.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
@@ -1123,6 +1125,29 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stripe/react-stripe-js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.7.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@stripe/stripe-js": ">=8.0.0 <9.0.0",
|
||||||
|
"react": ">=16.8.0 <20.0.0",
|
||||||
|
"react-dom": ">=16.8.0 <20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@stripe/stripe-js": {
|
||||||
|
"version": "8.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.0.tgz",
|
||||||
|
"integrity": "sha512-EB0/GGgs4hfezzkiMkinlRgWtjz8fSdwVQhwYS7Sg/RQrSvuNOz+ssPjD+lAzqaYTCB0zlbrt0fcqVziLJrufQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
@@ -2851,7 +2876,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -3262,6 +3286,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/loose-envify": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"loose-envify": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -3403,6 +3439,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
@@ -3620,6 +3665,23 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"test": "vitest --coverage"
|
"test": "vitest --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@stripe/react-stripe-js": "^5.4.1",
|
||||||
|
"@stripe/stripe-js": "^8.6.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,3 +1,19 @@
|
|||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment,
|
||||||
|
.previous-donations {
|
||||||
|
--max-section-width: 31.25rem;
|
||||||
|
flex: 1 1 var(--max-section-width);
|
||||||
|
max-width: var(--max-section-width);
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
background-color: #181818;
|
background-color: #181818;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -25,15 +41,17 @@ header .info {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header .info p {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background-color: #181818;
|
background-color: #181818;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
|
||||||
max-width: 31.25rem;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
display: flex;
|
|
||||||
box-shadow: 0 4px 8px #0000001a;
|
box-shadow: 0 4px 8px #0000001a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +65,17 @@ form label {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form label .detail {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
form label:has(+ input:required)::after {
|
||||||
|
content: ' [required]';
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
form input {
|
form input {
|
||||||
color: #e4e4e4;
|
color: #e4e4e4;
|
||||||
background-color: #282828;
|
background-color: #282828;
|
||||||
@@ -69,6 +98,15 @@ form input[type='number'] {
|
|||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form textarea {
|
||||||
|
color: #e4e4e4;
|
||||||
|
background-color: #282828;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -83,3 +121,152 @@ form button {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-confirmation-loading {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-confirmation-loading .spinner {
|
||||||
|
border: 4px solid #444;
|
||||||
|
border-top: 4px solid #b39cd0;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
margin: 0 auto 1rem auto;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-confirmation-card {
|
||||||
|
background-color: #181818;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 31.25rem;
|
||||||
|
box-shadow: 0 4px 8px #0000001a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-donations {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-donations ul {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-donations ul li {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previous-donations .load-more-button {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #b39cd0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #282828;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .left,
|
||||||
|
.payment-card .right {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .left img {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .header .right {
|
||||||
|
flex: 1;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .header .info,
|
||||||
|
.payment-card .header .details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .header .right .info .date {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .header .right .details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .header .right .details .amount {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .header .right .info,
|
||||||
|
.payment-card .header .right .details .amount,
|
||||||
|
.payment-card .message {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-card .message {
|
||||||
|
font-style: italic;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-home-link {
|
||||||
|
color: #b39cd0;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,64 +1,57 @@
|
|||||||
import '@/App.css';
|
import '@/App.css';
|
||||||
import { useRef, useState } from 'react';
|
import { Elements } from '@stripe/react-stripe-js';
|
||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import CheckoutForm from './CheckoutForm';
|
||||||
|
import PaymentForm from './PaymentForm';
|
||||||
|
import PaymentConfirmationCard from './PaymentConfirmationCard';
|
||||||
|
import PreviousPaymentsList from './PreviousPaymentsList';
|
||||||
|
|
||||||
|
const stripe = loadStripe(import.meta.env.VITE_STRIPE_API_KEY as string);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [amount, setAmount] = useState<number | ''>('');
|
const queryParams = new URLSearchParams(window.location.search);
|
||||||
const [email, setEmail] = useState<string>('');
|
const clientSecretFromUrl = queryParams.get('payment_intent_client_secret');
|
||||||
const [errors, setErrors] = useState<{ amount?: string; email?: string }>({});
|
const [secret, setSecret] = useState<string | undefined>(clientSecretFromUrl ?? undefined);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
|
||||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
async function handleDonationFormSubmit(formData: {
|
||||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
message?: string;
|
||||||
|
email?: string;
|
||||||
|
}) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
function handleAmountInput(event: React.ChangeEvent<HTMLInputElement>) {
|
try {
|
||||||
setErrors((prevErrors) => ({ ...prevErrors, amount: undefined }));
|
const url = new URL('/payments/create-intent', import.meta.url);
|
||||||
const value = parseInt(event.target.value);
|
const res = await fetch(url, {
|
||||||
setAmount(isNaN(value) ? '' : value);
|
method: 'POST',
|
||||||
}
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.name,
|
||||||
|
amount: formData.amount,
|
||||||
|
message: formData.message,
|
||||||
|
email: formData.email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
function handleEmailInput(event: React.ChangeEvent<HTMLInputElement>) {
|
if (!res.ok) {
|
||||||
setErrors((prevErrors) => ({ ...prevErrors, email: undefined }));
|
console.error('Failed to create payment intent');
|
||||||
setEmail(event.target.value);
|
alert('An error occurred while creating the payment. Please try again.');
|
||||||
}
|
return;
|
||||||
|
|
||||||
function validateForm() {
|
|
||||||
const newErrors: { amount?: string; email?: string } = {};
|
|
||||||
|
|
||||||
if (amount === '' || amount <= 0) {
|
|
||||||
newErrors.amount = 'Please enter a valid amount greater than 0.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email.trim() !== '' && emailInputRef.current?.validity.typeMismatch) {
|
|
||||||
newErrors.email = 'Please enter a valid email address.';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
|
|
||||||
const isValid = Object.keys(newErrors).length === 0;
|
|
||||||
|
|
||||||
if (isValid === false) {
|
|
||||||
if (newErrors.amount) {
|
|
||||||
amountInputRef.current?.focus();
|
|
||||||
} else if (newErrors.email) {
|
|
||||||
emailInputRef.current?.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as { clientSecret: string };
|
||||||
|
setSecret(data.clientSecret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('An error occurred while creating the payment. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return isValid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const isValid = validateForm();
|
|
||||||
|
|
||||||
if (isValid === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: We will stop displaying our form
|
|
||||||
// and we will initialize the stripe embedded
|
|
||||||
// elements and pass along the amount and email
|
|
||||||
alert(`Form is valid! Amount: ${amount}, Email: ${email}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,49 +65,58 @@ function App() {
|
|||||||
<h1>Stevan Freeborn</h1>
|
<h1>Stevan Freeborn</h1>
|
||||||
<p>
|
<p>
|
||||||
I'm a dad of 2 who enjoys drinking coffee, lifting weights, and solving problems with
|
I'm a dad of 2 who enjoys drinking coffee, lifting weights, and solving problems with
|
||||||
code. You really don't need to buy me a coffee.
|
code. If you have found my open helpful, consider supporting me with a donation which I
|
||||||
|
will more than likely spend on more coffee!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<form
|
<section className='payment'>
|
||||||
onSubmit={handleSubmit}
|
{secret === undefined ? (
|
||||||
noValidate
|
<PaymentForm
|
||||||
>
|
onValidSubmit={handleDonationFormSubmit}
|
||||||
<div className='group'>
|
isSubmitting={isSubmitting}
|
||||||
<label htmlFor='amount'>Amount</label>
|
|
||||||
<input
|
|
||||||
ref={amountInputRef}
|
|
||||||
id='amount'
|
|
||||||
type='number'
|
|
||||||
min={1}
|
|
||||||
aria-describedby='amountErrorMessage'
|
|
||||||
aria-invalid={errors.amount ? 'true' : 'false'}
|
|
||||||
value={amount}
|
|
||||||
onInput={handleAmountInput}
|
|
||||||
/>
|
/>
|
||||||
<span className='error-message'>{errors.amount}</span>
|
) : (
|
||||||
</div>
|
<Elements
|
||||||
<div className='group'>
|
options={{
|
||||||
<label htmlFor='email'>Email</label>
|
clientSecret: secret,
|
||||||
<input
|
loader: 'auto',
|
||||||
ref={emailInputRef}
|
appearance: {
|
||||||
id='email'
|
theme: 'stripe',
|
||||||
type='email'
|
disableAnimations: true,
|
||||||
aria-describedby='emailErrorMessage'
|
variables: {
|
||||||
aria-invalid={errors.email ? 'true' : 'false'}
|
colorBackground: '#181818',
|
||||||
value={email}
|
colorPrimary: '#E4E4E4',
|
||||||
onInput={handleEmailInput}
|
colorText: '#E4E4E4',
|
||||||
/>
|
fontFamily: 'CaskaydiaCove NFM, monospace',
|
||||||
<span
|
fontSizeBase: '16px',
|
||||||
id='emailErrorMessage'
|
borderRadius: '0.25rem',
|
||||||
className='error-message'
|
colorDanger: '#ff6b6b',
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'.Label': {
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
'.Input': {
|
||||||
|
backgroundColor: '#282828',
|
||||||
|
border: '1px solid #444',
|
||||||
|
padding: '0.5rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
stripe={stripe}
|
||||||
>
|
>
|
||||||
{errors.email}
|
{clientSecretFromUrl ? (
|
||||||
</span>
|
<PaymentConfirmationCard clientSecret={clientSecretFromUrl} />
|
||||||
</div>
|
) : (
|
||||||
<button type='submit'>Buy</button>
|
<CheckoutForm />
|
||||||
</form>
|
)}
|
||||||
|
</Elements>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{clientSecretFromUrl === null ? <PreviousPaymentsList /> : null}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function CheckoutForm() {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const isLoading = !stripe || !elements;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!stripe || !elements) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await stripe.confirmPayment({
|
||||||
|
elements,
|
||||||
|
confirmParams: {
|
||||||
|
return_url: window.location.origin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
alert(error?.message || 'An unexpected error occurred.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={e => void handleSubmit(e)}>
|
||||||
|
<PaymentElement
|
||||||
|
id='payment-element'
|
||||||
|
options={{ layout: 'accordion' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={isLoading || isSubmitting}
|
||||||
|
id='submit'
|
||||||
|
>
|
||||||
|
<span id='button-text'>Pay</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type Payment = {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
message?: string;
|
||||||
|
createdAtUnix: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PaymentCard({ payment }: { payment: Payment }) {
|
||||||
|
const formattedPayment = payment.amount / 100;
|
||||||
|
|
||||||
|
const date = new Date(payment.createdAtUnix);
|
||||||
|
const userLocale = navigator.language || 'en-US';
|
||||||
|
const formattedDate = date.toLocaleDateString(userLocale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='payment-card'>
|
||||||
|
<div className='header'>
|
||||||
|
<div className='left'>
|
||||||
|
<img
|
||||||
|
src='/espresso.png'
|
||||||
|
alt='Avatar'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='right'>
|
||||||
|
<div className='info'>
|
||||||
|
<div>{payment.name}</div>
|
||||||
|
<div className='date'>{formattedDate}</div>
|
||||||
|
</div>
|
||||||
|
<div className='details'>
|
||||||
|
<div className='amount'>${formattedPayment.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{payment.message && <div className='message'>{payment.message}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useStripe } from '@stripe/react-stripe-js';
|
||||||
|
import type { PaymentIntent } from '@stripe/stripe-js';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type PaymentConfirmationCardProps = {
|
||||||
|
clientSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaymentIntentData =
|
||||||
|
| {
|
||||||
|
status: 'loading';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'loaded';
|
||||||
|
intent: PaymentIntent;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'error';
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PaymentConfirmationCard({ clientSecret }: PaymentConfirmationCardProps) {
|
||||||
|
const stripe = useStripe();
|
||||||
|
|
||||||
|
const [data, setData] = useState<PaymentIntentData>({ status: 'loading' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchPaymentIntent() {
|
||||||
|
if (!stripe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { paymentIntent, error } = await stripe.retrievePaymentIntent(clientSecret);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setData({ status: 'error', error: error.message ?? 'Unknown error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentIntent) {
|
||||||
|
setData({ status: 'loaded', intent: paymentIntent });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setData({ status: 'error', error: 'PaymentIntent not found' });
|
||||||
|
} catch (err) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
setData({ status: 'error', error: (err as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchPaymentIntent();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [stripe, clientSecret]);
|
||||||
|
|
||||||
|
if (data.status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className='payment-confirmation-loading'>
|
||||||
|
<div className='spinner' />
|
||||||
|
<span>Checking payment status...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='payment-confirmation-card'>
|
||||||
|
{data.status === 'error' ? (
|
||||||
|
<>
|
||||||
|
<h2>Payment Error</h2>
|
||||||
|
<p>There was an error processing your payment: {data.error}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2>Thank you!</h2>
|
||||||
|
<p>Your payment was successful. I appreciate your support!</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
className='return-home-link'
|
||||||
|
href='/'
|
||||||
|
>
|
||||||
|
Return to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
|
||||||
|
type DonationFormProps = {
|
||||||
|
onValidSubmit: (data: { name: string; amount: number; message?: string; email?: string }) => Promise<void> | void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PaymentForm({ onValidSubmit, isSubmitting }: DonationFormProps) {
|
||||||
|
const [name, setName] = useState<string>('');
|
||||||
|
const [amount, setAmount] = useState<number | ''>('');
|
||||||
|
const [message, setMessage] = useState<string>('');
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
name?: string;
|
||||||
|
amount?: string;
|
||||||
|
email?: string;
|
||||||
|
message?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const messageTextAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function handleNameInput(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
setErrors(prevErrors => ({ ...prevErrors, name: undefined }));
|
||||||
|
setName(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAmountInput(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
setErrors(prevErrors => ({ ...prevErrors, amount: undefined }));
|
||||||
|
const value = parseInt(event.target.value);
|
||||||
|
setAmount(isNaN(value) ? '' : value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessageInput(event: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||||
|
setErrors(prevErrors => ({ ...prevErrors, message: undefined }));
|
||||||
|
setMessage(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmailInput(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
setErrors(prevErrors => ({ ...prevErrors, email: undefined }));
|
||||||
|
setEmail(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
const newErrors: { amount?: string; email?: string; name?: string; message?: string } = {};
|
||||||
|
|
||||||
|
if (name.trim() === '') {
|
||||||
|
newErrors.name = 'Please enter your name.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameInputRef.current?.validity.tooLong) {
|
||||||
|
newErrors.name = 'Name must be 60 characters or less.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount === '' || amountInputRef.current?.validity.rangeUnderflow || amount <= 0) {
|
||||||
|
newErrors.amount = 'Please enter a valid amount greater than 0.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length > 250) {
|
||||||
|
newErrors.message = 'Message must be 250 characters or less.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email.trim() !== '' && emailInputRef.current?.validity.typeMismatch) {
|
||||||
|
newErrors.email = 'Please enter a valid email address.';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
|
||||||
|
const isValid = Object.keys(newErrors).length === 0;
|
||||||
|
|
||||||
|
if (isValid === false) {
|
||||||
|
if (newErrors.name) {
|
||||||
|
nameInputRef.current?.focus();
|
||||||
|
} else if (newErrors.amount) {
|
||||||
|
amountInputRef.current?.focus();
|
||||||
|
} else if (newErrors.message) {
|
||||||
|
messageTextAreaRef.current?.focus();
|
||||||
|
} else if (newErrors.email) {
|
||||||
|
emailInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const isValid = validateForm();
|
||||||
|
|
||||||
|
if (isValid === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount === '') {
|
||||||
|
throw new Error('amount after validation should never be an empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
await onValidSubmit({ name, amount, message, email });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={e => void handleSubmit(e)}
|
||||||
|
noValidate
|
||||||
|
>
|
||||||
|
<div className='group'>
|
||||||
|
<label htmlFor='name'>Name</label>
|
||||||
|
<input
|
||||||
|
ref={nameInputRef}
|
||||||
|
id='name'
|
||||||
|
type='text'
|
||||||
|
required
|
||||||
|
maxLength={60}
|
||||||
|
aria-describedby='nameErrorMessage'
|
||||||
|
aria-invalid={errors.name ? 'true' : 'false'}
|
||||||
|
value={name}
|
||||||
|
onInput={handleNameInput}
|
||||||
|
placeholder='What do I call you?'
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
id='nameErrorMessage'
|
||||||
|
className='error-message'
|
||||||
|
>
|
||||||
|
{errors.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='group'>
|
||||||
|
<label htmlFor='amount'>Amount</label>
|
||||||
|
<input
|
||||||
|
ref={amountInputRef}
|
||||||
|
id='amount'
|
||||||
|
type='number'
|
||||||
|
required
|
||||||
|
min={1}
|
||||||
|
aria-describedby='amountErrorMessage'
|
||||||
|
aria-invalid={errors.amount ? 'true' : 'false'}
|
||||||
|
value={amount}
|
||||||
|
onInput={handleAmountInput}
|
||||||
|
placeholder='1'
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
id='amountErrorMessage'
|
||||||
|
className='error-message'
|
||||||
|
>
|
||||||
|
{errors.amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='group'>
|
||||||
|
<label htmlFor='message'>Message</label>
|
||||||
|
<textarea
|
||||||
|
id='message'
|
||||||
|
ref={messageTextAreaRef}
|
||||||
|
aria-describedby='messageErrorMessage'
|
||||||
|
aria-invalid={errors.message ? 'true' : 'false'}
|
||||||
|
value={message}
|
||||||
|
maxLength={250}
|
||||||
|
onInput={handleMessageInput}
|
||||||
|
placeholder='Let me know what I did for you!'
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
id='messageErrorMessage'
|
||||||
|
className='error-message'
|
||||||
|
>
|
||||||
|
{errors.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='group'>
|
||||||
|
<div className='email-label'>
|
||||||
|
<label htmlFor='email'>Email </label>
|
||||||
|
<span className='detail'>[if you want a receipt]</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={emailInputRef}
|
||||||
|
id='email'
|
||||||
|
type='email'
|
||||||
|
aria-describedby='emailErrorMessage'
|
||||||
|
aria-invalid={errors.email ? 'true' : 'false'}
|
||||||
|
value={email}
|
||||||
|
onInput={handleEmailInput}
|
||||||
|
placeholder='hello@world.com'
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
id='emailErrorMessage'
|
||||||
|
className='error-message'
|
||||||
|
>
|
||||||
|
{errors.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { Payment } from './PaymentCard';
|
||||||
|
import PaymentCard from './PaymentCard';
|
||||||
|
|
||||||
|
type PaymentPage = {
|
||||||
|
totalNumberOfPayments: number;
|
||||||
|
totalNumberOfPages: number;
|
||||||
|
currentPageNumber: number;
|
||||||
|
payments: Payment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaymentData =
|
||||||
|
| {
|
||||||
|
status: 'loading';
|
||||||
|
}
|
||||||
|
| { status: 'error'; message: string }
|
||||||
|
| {
|
||||||
|
status: 'success';
|
||||||
|
pages: PaymentPage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PreviousPaymentsList() {
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
|
||||||
|
const [paymentData, setPaymentData] = useState<PaymentData>({ status: 'loading' });
|
||||||
|
const payments =
|
||||||
|
paymentData.status === 'success' ? paymentData.pages.flatMap(page => page.payments) : [];
|
||||||
|
const currentPageNumber =
|
||||||
|
paymentData.status === 'success'
|
||||||
|
? paymentData.pages[paymentData.pages.length - 1].currentPageNumber
|
||||||
|
: 0;
|
||||||
|
const totalNumberOfPages =
|
||||||
|
paymentData.status === 'success' ? paymentData.pages[0].totalNumberOfPages : 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
async function fetchPayments() {
|
||||||
|
try {
|
||||||
|
const url = new URL('/payments', import.meta.url);
|
||||||
|
const res = await fetch(url);
|
||||||
|
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setPaymentData({ status: 'error', message: 'Failed to fetch payments' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as PaymentPage;
|
||||||
|
|
||||||
|
setPaymentData({ status: 'success', pages: [data] });
|
||||||
|
} catch (err) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(err);
|
||||||
|
setPaymentData({ status: 'error', message: 'Failed to fetch payments' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchPayments();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleLoadMoreButtonClick() {
|
||||||
|
if (paymentData.status !== 'success') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextPageNumber = currentPageNumber + 1;
|
||||||
|
const url = new URL('/payments', import.meta.url);
|
||||||
|
const queryParams = new URLSearchParams({ pageNumber: nextPageNumber.toString() });
|
||||||
|
const res = await fetch(`${url.toString()}?${queryParams.toString()}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to fetch more payments');
|
||||||
|
alert('Failed to load more payments. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as PaymentPage;
|
||||||
|
|
||||||
|
setPaymentData({
|
||||||
|
status: 'success',
|
||||||
|
pages: [...paymentData.pages, data],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to load more payments. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paymentData.status === 'loading' ? (
|
||||||
|
<div>Loading previous donations...</div>
|
||||||
|
) : paymentData.status === 'error' ? (
|
||||||
|
<div className='error'>Failed to load previous donations</div>
|
||||||
|
) : (
|
||||||
|
<section className='previous-donations'>
|
||||||
|
{payments.length == 0 ? null : (
|
||||||
|
<>
|
||||||
|
<ul>
|
||||||
|
{payments.map((payment, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<PaymentCard payment={payment} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{currentPageNumber === totalNumberOfPages ? null : (
|
||||||
|
<button
|
||||||
|
className='load-more-button'
|
||||||
|
type='button'
|
||||||
|
onClick={() => void handleLoadMoreButtonClick()}
|
||||||
|
disabled={currentPageNumber >= totalNumberOfPages || isLoadingMore}
|
||||||
|
>
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'CaskaydiaCove NFM';
|
font-family: 'CaskaydiaCove NFM';
|
||||||
src: url('/CaskaydiaCoveNFM-Regular.eot');
|
src: url('/CaskaydiaCoveNFM-Regular.eot');
|
||||||
src: url('/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
|
src:
|
||||||
url('/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
|
url('/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
|
||||||
url('/CaskaydiaCoveNFM-Regular.woff') format('woff'),
|
url('/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
|
||||||
url('/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
|
url('/CaskaydiaCoveNFM-Regular.woff') format('woff'),
|
||||||
font-weight: normal;
|
url('/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
|
||||||
font-style: normal;
|
font-weight: normal;
|
||||||
font-display: swap;
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CSS Reset */
|
/* CSS Reset */
|
||||||
@@ -56,7 +57,7 @@ table {
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color: #323232;
|
--bg-color: #323232;
|
||||||
--text-color: #E4E4E4;
|
--text-color: #e4e4e4;
|
||||||
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-family: 'CaskaydiaCove NFM', monospace;
|
font-family: 'CaskaydiaCove NFM', monospace;
|
||||||
@@ -69,12 +70,3 @@ body {
|
|||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
padding-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import App from '@/App.tsx';
|
import App from '@/App.tsx';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
@@ -7,7 +7,7 @@ import userEvent, { type UserEvent } from '@testing-library/user-event';
|
|||||||
describe('profile info', () => {
|
describe('profile info', () => {
|
||||||
test('it should display a profile image', () => {
|
test('it should display a profile image', () => {
|
||||||
const screen = render(<App />);
|
const screen = render(<App />);
|
||||||
|
|
||||||
const profileImage = screen.getByAltText('Profile picture of Stevan Freeborn');
|
const profileImage = screen.getByAltText('Profile picture of Stevan Freeborn');
|
||||||
|
|
||||||
expect(profileImage).toBeInTheDocument();
|
expect(profileImage).toBeInTheDocument();
|
||||||
@@ -25,8 +25,36 @@ describe('profile info', () => {
|
|||||||
describe('form', () => {
|
describe('form', () => {
|
||||||
let user: UserEvent;
|
let user: UserEvent;
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
user = userEvent.setup();
|
user = userEvent.setup();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
mockFetch.mockImplementation((url: URL) => {
|
||||||
|
if (url.toString().endsWith('/payments/create-intent')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: () => ({
|
||||||
|
clientSecret: 'test_client_secret',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.toString().endsWith('/payments')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: () => ({
|
||||||
|
payments: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
mockFetch.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it should have an amount input', () => {
|
test('it should have an amount input', () => {
|
||||||
@@ -83,8 +111,6 @@ describe('form', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('it should allow form to be submitted without email', async () => {
|
test('it should allow form to be submitted without email', async () => {
|
||||||
const windowSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const screen = render(<App />);
|
const screen = render(<App />);
|
||||||
|
|
||||||
const amountInput = screen.getByLabelText('Amount');
|
const amountInput = screen.getByLabelText('Amount');
|
||||||
@@ -92,8 +118,6 @@ describe('form', () => {
|
|||||||
|
|
||||||
await user.type(amountInput, '10');
|
await user.type(amountInput, '10');
|
||||||
await user.click(submitButton);
|
await user.click(submitButton);
|
||||||
|
|
||||||
expect(windowSpy).toHaveBeenCalledWith('Form is valid! Amount: 10, Email: ');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it should validate email if provided', async () => {
|
test('it should validate email if provided', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using GroundsForSupport.Server.Payments;
|
||||||
|
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Data;
|
||||||
|
|
||||||
|
internal sealed class Context(
|
||||||
|
IOptions<ContextOptions> ctxOptions,
|
||||||
|
DbContextOptions<Context> options
|
||||||
|
) : DbContext(options)
|
||||||
|
{
|
||||||
|
private const string DataSourceKey = "Data Source=";
|
||||||
|
private readonly ContextOptions _ctxOptions = ctxOptions.Value;
|
||||||
|
|
||||||
|
public DbSet<Payment> Payments => Set<Payment>();
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
var dbPath = _ctxOptions.GetFullyQualifiedDatabasePath();
|
||||||
|
var dbDirectory = Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined.");
|
||||||
|
|
||||||
|
if (Directory.Exists(dbDirectory) is false)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(dbDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectionString = $"{DataSourceKey}{dbPath}";
|
||||||
|
|
||||||
|
optionsBuilder.UseSqlite(connectionString);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Data;
|
||||||
|
|
||||||
|
internal sealed record ContextOptions
|
||||||
|
{
|
||||||
|
public string DatabaseFilePath { get; init; } = string.Empty;
|
||||||
|
public string GetFullyQualifiedDatabasePath()
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(DatabaseFilePath, AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record ContextOptionsSetup : IConfigureOptions<ContextOptions>
|
||||||
|
{
|
||||||
|
private const string SectionName = nameof(ContextOptions);
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
public ContextOptionsSetup(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(ContextOptions options)
|
||||||
|
{
|
||||||
|
_configuration.GetSection(SectionName).Bind(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Data;
|
||||||
|
|
||||||
|
internal sealed class MigrationService(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<MigrationService> logger
|
||||||
|
) : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _serviceProvider = serviceProvider;
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Applying database migrations...");
|
||||||
|
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<Context>();
|
||||||
|
await context.Database.MigrateAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Database migrations applied.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,17 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Stripe.net" Version="50.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
|
||||||
|
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="InstallClientDeps" BeforeTargets="Build">
|
<Target Name="InstallClientDeps" BeforeTargets="Build">
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Logging;
|
||||||
|
|
||||||
|
internal sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger) : IExceptionHandler
|
||||||
|
{
|
||||||
|
private readonly ILogger<GlobalExceptionHandler> _logger = logger;
|
||||||
|
|
||||||
|
public async ValueTask<bool> TryHandleAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "Exception occurred: {Message}", exception.Message);
|
||||||
|
|
||||||
|
var problemDetails = new ProblemDetails
|
||||||
|
{
|
||||||
|
Status = StatusCodes.Status500InternalServerError,
|
||||||
|
Title = "Server Error",
|
||||||
|
Detail = "An unexpected error occurred. Please try again later."
|
||||||
|
};
|
||||||
|
|
||||||
|
httpContext.Response.StatusCode = problemDetails.Status.Value;
|
||||||
|
|
||||||
|
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(Context))]
|
||||||
|
[Migration("20251230125831_AddPaymentsModel")]
|
||||||
|
partial class AddPaymentsModel
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||||
|
|
||||||
|
modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Payments");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPaymentsModel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Payments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Payments", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Payments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+37
@@ -0,0 +1,37 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(Context))]
|
||||||
|
[Migration("20251231131814_AddAmountToPaymentModel")]
|
||||||
|
partial class AddAmountToPaymentModel
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||||
|
|
||||||
|
modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Amount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Payments");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAmountToPaymentModel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "Amount",
|
||||||
|
table: "Payments",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Amount",
|
||||||
|
table: "Payments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(Context))]
|
||||||
|
[Migration("20260102131600_AddNameMessagePropertiesToPaymentModel")]
|
||||||
|
partial class AddNameMessagePropertiesToPaymentModel
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||||
|
|
||||||
|
modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Amount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Payments");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNameMessagePropertiesToPaymentModel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Message",
|
||||||
|
table: "Payments",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Name",
|
||||||
|
table: "Payments",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Message",
|
||||||
|
table: "Payments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Name",
|
||||||
|
table: "Payments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(Context))]
|
||||||
|
[Migration("20260102140326_AddCreatedAtTimeStampToPaymentsModel")]
|
||||||
|
partial class AddCreatedAtTimeStampToPaymentsModel
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||||
|
|
||||||
|
modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Amount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAtUnix")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Payments");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCreatedAtTimeStampToPaymentsModel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "CreatedAtUnix",
|
||||||
|
table: "Payments",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CreatedAtUnix",
|
||||||
|
table: "Payments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(Context))]
|
||||||
|
partial class ContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
|
||||||
|
|
||||||
|
modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("Amount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("CreatedAtUnix")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Payments");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
using GroundsForSupport.Server.Payments.Stripe;
|
||||||
|
using GroundsForSupport.Server.RateLimiting;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Payments.Endpoints;
|
||||||
|
|
||||||
|
internal static class CreatePaymentIntentEndpoint
|
||||||
|
{
|
||||||
|
private const string Route = "/payments/create-intent";
|
||||||
|
|
||||||
|
public static IEndpointConventionBuilder MapCreatePaymentIntentEndpoint(this WebApplication app)
|
||||||
|
{
|
||||||
|
return app.MapPost(Route, CreatePaymentIntentHandler).RequireRateLimiting(FixedRateLimitPolicy.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<IResult> CreatePaymentIntentHandler(
|
||||||
|
Request request,
|
||||||
|
IStripeService stripeService,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var validationErrors = request.Validate(new ValidationContext(request));
|
||||||
|
|
||||||
|
if (validationErrors.Any())
|
||||||
|
{
|
||||||
|
return Results.ValidationProblem(validationErrors
|
||||||
|
.GroupBy(static e => e.MemberNames.FirstOrDefault() ?? string.Empty)
|
||||||
|
.ToDictionary(static g => g.Key, static g => g.Select(static e => e.ErrorMessage ?? string.Empty).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
var (isSuccess, intent) = await stripeService.CreatePaymentIntentAsync(
|
||||||
|
request.Name,
|
||||||
|
request.Amount,
|
||||||
|
request.Message,
|
||||||
|
request.Email,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSuccess is false)
|
||||||
|
{
|
||||||
|
return Results.InternalServerError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record Request(
|
||||||
|
string Name,
|
||||||
|
decimal Amount,
|
||||||
|
string? Message,
|
||||||
|
string? Email
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Name))
|
||||||
|
{
|
||||||
|
yield return new ValidationResult("Name is required", [nameof(Name)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Amount <= 0)
|
||||||
|
{
|
||||||
|
yield return new ValidationResult("Amount must be greater than zero", [nameof(Amount)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Message?.Length > 250)
|
||||||
|
{
|
||||||
|
yield return new ValidationResult("Message cannot exceed 250 characters", [nameof(Message)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(Email) is false && new EmailAddressAttribute().IsValid(Email) is false)
|
||||||
|
{
|
||||||
|
yield return new ValidationResult("Email is not valid", [nameof(Email)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record Response(string ClientSecret);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
using GroundsForSupport.Server.Payments.Stripe;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Payments.Endpoints;
|
||||||
|
|
||||||
|
internal static class EventsEndpoint
|
||||||
|
{
|
||||||
|
private const string Route = "/payments/events";
|
||||||
|
private const string StripeSignatureHeader = "Stripe-Signature";
|
||||||
|
|
||||||
|
internal static IEndpointConventionBuilder MapEventsEndpoint(this WebApplication app)
|
||||||
|
{
|
||||||
|
return app.MapPost(Route, EventsHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static async Task<IResult> EventsHandler(
|
||||||
|
HttpContext httpContext,
|
||||||
|
[FromServices] IOptions<StripeOptions> options,
|
||||||
|
[FromServices] Context dbContext,
|
||||||
|
[FromServices] TimeProvider timeProvider
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var json = await new StreamReader(httpContext.Request.Body).ReadToEndAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var signatureHeader = httpContext.Request.Headers[StripeSignatureHeader];
|
||||||
|
var stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.EventsWebhookSecret);
|
||||||
|
|
||||||
|
if (stripeEvent.Type is not EventTypes.PaymentIntentSucceeded)
|
||||||
|
{
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
var paymentIntent = (PaymentIntent)stripeEvent.Data.Object;
|
||||||
|
|
||||||
|
var name = paymentIntent.Metadata.TryGetValue(nameof(Payment.Name), out var metaName)
|
||||||
|
? metaName
|
||||||
|
: "unknown";
|
||||||
|
|
||||||
|
var message = paymentIntent.Metadata.TryGetValue(nameof(Payment.Message), out var metaMessage)
|
||||||
|
? metaMessage
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var payment = new Payment()
|
||||||
|
{
|
||||||
|
Id = paymentIntent.Id,
|
||||||
|
Amount = paymentIntent.AmountReceived,
|
||||||
|
Name = name,
|
||||||
|
Message = message,
|
||||||
|
CreatedAtUnix = timeProvider.GetUtcNow().ToUnixTimeMilliseconds(),
|
||||||
|
};
|
||||||
|
|
||||||
|
dbContext.Payments.Add(payment);
|
||||||
|
await dbContext.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok();
|
||||||
|
}
|
||||||
|
catch (StripeException e)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Stripe exception: {e.Message}");
|
||||||
|
return Results.BadRequest(new { error = e.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Payments.Endpoints;
|
||||||
|
|
||||||
|
internal static class GetPaymentsEndpoint
|
||||||
|
{
|
||||||
|
private const string Route = "/payments";
|
||||||
|
|
||||||
|
public static IEndpointConventionBuilder MapGetPaymentsEndpoint(this WebApplication app)
|
||||||
|
{
|
||||||
|
return app.MapGet(Route, GetPaymentsHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<IResult> GetPaymentsHandler(
|
||||||
|
[AsParameters] Request request,
|
||||||
|
[FromServices] Context context
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var validationErrors = request.Validate(new ValidationContext(request));
|
||||||
|
|
||||||
|
if (validationErrors.Any())
|
||||||
|
{
|
||||||
|
return Results.ValidationProblem(validationErrors
|
||||||
|
.GroupBy(static e => e.MemberNames.FirstOrDefault() ?? string.Empty)
|
||||||
|
.ToDictionary(static g => g.Key, static g => g.Select(static e => e.ErrorMessage ?? string.Empty).ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalNumberOfPayments = context.Payments.Count();
|
||||||
|
var totalNumberOfPages = (int)Math.Ceiling(totalNumberOfPayments / (double)request.PageSize);
|
||||||
|
|
||||||
|
var paymentsQuery = request.SortDirection?.ToLower(CultureInfo.CurrentCulture) is "asc"
|
||||||
|
? context.Payments
|
||||||
|
.OrderBy(static p => p.CreatedAtUnix)
|
||||||
|
: context.Payments
|
||||||
|
.OrderByDescending(static p => p.CreatedAtUnix);
|
||||||
|
|
||||||
|
var payments = paymentsQuery
|
||||||
|
.Skip((request.PageNumber - 1) * request.PageSize)
|
||||||
|
.Take(request.PageSize)
|
||||||
|
.Select(PaymentRecord.From)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var response = new Response(
|
||||||
|
totalNumberOfPayments,
|
||||||
|
totalNumberOfPages,
|
||||||
|
request.PageNumber,
|
||||||
|
payments
|
||||||
|
);
|
||||||
|
|
||||||
|
return Results.Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record Request(int PageSize = 50, int PageNumber = 1, string? SortDirection = null) : IValidatableObject
|
||||||
|
{
|
||||||
|
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (PageSize <= 0)
|
||||||
|
{
|
||||||
|
yield return new ValidationResult("PageSize must be greater than zero", [nameof(PageSize)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PageNumber <= 0)
|
||||||
|
{
|
||||||
|
yield return new ValidationResult("PageNumber must be greater than zero", [nameof(PageNumber)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
SortDirection is not null &&
|
||||||
|
SortDirection.ToLower(CultureInfo.CurrentCulture) is not "asc" and not "desc"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
yield return new ValidationResult("SortDirection must be either 'asc' or 'desc'", [nameof(SortDirection)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record Response(
|
||||||
|
int TotalNumberOfPayments,
|
||||||
|
int TotalNumberOfPages,
|
||||||
|
int CurrentPageNumber,
|
||||||
|
List<PaymentRecord> Payments
|
||||||
|
);
|
||||||
|
|
||||||
|
internal sealed record PaymentRecord
|
||||||
|
{
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public long Amount { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public long CreatedAtUnix { get; init; }
|
||||||
|
|
||||||
|
public static PaymentRecord From(Payment payment)
|
||||||
|
{
|
||||||
|
return new PaymentRecord
|
||||||
|
{
|
||||||
|
Amount = payment.Amount,
|
||||||
|
Name = payment.Name,
|
||||||
|
Message = payment.Message,
|
||||||
|
CreatedAtUnix = payment.CreatedAtUnix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace GroundsForSupport.Server.Payments;
|
||||||
|
|
||||||
|
internal sealed record Intent(string ClientSecret);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace GroundsForSupport.Server.Payments;
|
||||||
|
|
||||||
|
internal sealed class Payment
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required long Amount { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public long CreatedAtUnix { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace GroundsForSupport.Server.Payments.Stripe;
|
||||||
|
|
||||||
|
internal interface IStripeService
|
||||||
|
{
|
||||||
|
Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(
|
||||||
|
string name,
|
||||||
|
decimal amount,
|
||||||
|
string? message,
|
||||||
|
string? email,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Payments.Stripe;
|
||||||
|
|
||||||
|
internal sealed record StripeOptions
|
||||||
|
{
|
||||||
|
public string ApiKey { get; init; } = string.Empty;
|
||||||
|
public string EventsWebhookSecret { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record StripeOptionsSetup : IConfigureOptions<StripeOptions>
|
||||||
|
{
|
||||||
|
private const string SectionName = nameof(StripeOptions);
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
public StripeOptionsSetup(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(StripeOptions options)
|
||||||
|
{
|
||||||
|
_configuration.GetSection(SectionName).Bind(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Payments.Stripe;
|
||||||
|
|
||||||
|
internal sealed class StripeService(
|
||||||
|
IOptions<StripeOptions> options,
|
||||||
|
HttpClient httpClient,
|
||||||
|
ILogger<StripeService> logger
|
||||||
|
) : IStripeService
|
||||||
|
{
|
||||||
|
private readonly StripeClient _client = new(options.Value.ApiKey, httpClient: new SystemNetHttpClient(httpClient));
|
||||||
|
private readonly ILogger<StripeService> _logger = logger;
|
||||||
|
|
||||||
|
public async Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(
|
||||||
|
string name,
|
||||||
|
decimal amount,
|
||||||
|
string? message,
|
||||||
|
string? email,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var createOptions = new PaymentIntentCreateOptions
|
||||||
|
{
|
||||||
|
Description = "Grounds for Support Donation",
|
||||||
|
Amount = (long)(amount * 100),
|
||||||
|
Currency = "usd",
|
||||||
|
AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
},
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ nameof(Payment.Name), name },
|
||||||
|
{ nameof(Payment.Message), message ?? string.Empty },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email) is false)
|
||||||
|
{
|
||||||
|
createOptions.ReceiptEmail = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
var intent = await _client.V1.PaymentIntents.CreateAsync(createOptions, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
return (true, new Intent(intent.ClientSecret));
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to create Stripe payment intent for {Name} with amount {Amount}", name, amount);
|
||||||
|
return (false, new Intent(string.Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,68 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
using GroundsForSupport.Server.Logging;
|
||||||
|
using GroundsForSupport.Server.Payments.Endpoints;
|
||||||
|
using GroundsForSupport.Server.Payments.Stripe;
|
||||||
|
using GroundsForSupport.Server.RateLimiting;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
builder.Services.AddValidation();
|
||||||
|
|
||||||
|
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(
|
||||||
|
static options => options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.Services.AddRateLimingPolicies();
|
||||||
|
|
||||||
|
builder.Services.ConfigureOptions<ContextOptionsSetup>();
|
||||||
|
builder.Services.AddDbContext<Context>();
|
||||||
|
builder.Services.AddHostedService<MigrationService>();
|
||||||
|
|
||||||
|
builder.Services.ConfigureOptions<StripeOptionsSetup>();
|
||||||
|
builder.Services.AddSingleton<IStripeService, StripeService>();
|
||||||
|
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(
|
||||||
|
static options => options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseExceptionHandler();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (app.Environment.IsProduction())
|
||||||
|
{
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
app.UseStatusCodePages();
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
app.UseHsts();
|
||||||
|
|
||||||
|
app.MapCreatePaymentIntentEndpoint();
|
||||||
|
app.MapGetPaymentsEndpoint();
|
||||||
|
app.MapEventsEndpoint();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.RateLimiting;
|
||||||
|
|
||||||
|
internal static class Extensions
|
||||||
|
{
|
||||||
|
public static void AddRateLimingPolicies(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddRateLimiter(static o =>
|
||||||
|
{
|
||||||
|
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
o.OnRejected = static (context, ct) =>
|
||||||
|
{
|
||||||
|
var response = context.HttpContext.Response;
|
||||||
|
|
||||||
|
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||||
|
{
|
||||||
|
response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||||
|
var resetTime = DateTimeOffset.UtcNow.Add(retryAfter);
|
||||||
|
var unixTimeMilliseconds = resetTime.ToUnixTimeMilliseconds();
|
||||||
|
response.Headers.Append("X-RateLimit-Reset", unixTimeMilliseconds.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
o.AddPolicy(FixedRateLimitPolicy.Name, FixedRateLimitPolicy.Partitioner);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.RateLimiting;
|
||||||
|
|
||||||
|
internal static class FixedRateLimitPolicy
|
||||||
|
{
|
||||||
|
public const string Name = "fixed";
|
||||||
|
|
||||||
|
public static Func<HttpContext, RateLimitPartition<string>> Partitioner =>
|
||||||
|
static context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
factory: static partition => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 100,
|
||||||
|
Window = TimeSpan.FromHours(1),
|
||||||
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||||
|
QueueLimit = 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"StripeOptions": {
|
||||||
|
"ApiKey": "ApiKey",
|
||||||
|
"EventsWebhookSecret": "EventsWebhookSecret"
|
||||||
|
},
|
||||||
|
"ContextOptions": {
|
||||||
|
"DatabaseFilePath": "DatabaseFilePath"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[*.cs]
|
||||||
|
dotnet_diagnostic.CA1707.severity = none
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace GroundsForSupport.Server.Tests;
|
|
||||||
|
|
||||||
public class ExampleTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void SampleTest()
|
|
||||||
{
|
|
||||||
Assert.True(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-1
@@ -4,7 +4,7 @@
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<RootNamespace>GroundsForSupport.API.Tests</RootNamespace>
|
<RootNamespace>GroundsForSupport.Server.Tests</RootNamespace>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
|
<Using Include="AwesomeAssertions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -26,7 +27,9 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="xunit.v3" Version="3.2.1" />
|
<PackageReference Include="xunit.v3" Version="3.2.1" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using GroundsForSupport.Server.Data;
|
||||||
|
using GroundsForSupport.Server.Payments.Stripe;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GroundsForSupport.Server.Tests.Integration.Infra;
|
||||||
|
|
||||||
|
public sealed class TestApi : WebApplicationFactory<Program>
|
||||||
|
{
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
var contextOptions = new ContextOptions()
|
||||||
|
{
|
||||||
|
DatabaseFilePath = "GroundsForSupport.Test.db",
|
||||||
|
};
|
||||||
|
|
||||||
|
var stripeOptions = new StripeOptions()
|
||||||
|
{
|
||||||
|
ApiKey = "sk_test_12345",
|
||||||
|
};
|
||||||
|
|
||||||
|
var contextOptionsJson = JsonSerializer.Serialize(contextOptions);
|
||||||
|
var stripeOptionsJson = JsonSerializer.Serialize(stripeOptions);
|
||||||
|
|
||||||
|
var configJson = $@"{{
|
||||||
|
""{nameof(ContextOptions)}"": {contextOptionsJson},
|
||||||
|
""{nameof(StripeOptions)}"": {stripeOptionsJson}
|
||||||
|
}}";
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(configJson)))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
builder.UseConfiguration(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
using GroundsForSupport.Server.Payments;
|
||||||
|
using GroundsForSupport.Server.Payments.Stripe;
|
||||||
|
using GroundsForSupport.Server.Tests.Integration.Infra;
|
||||||
|
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
[assembly: CaptureConsole]
|
||||||
|
|
||||||
|
namespace GroundsForSupport.API.Tests.Integration;
|
||||||
|
|
||||||
|
public sealed class PaymentTests(TestApi api) : IClassFixture<TestApi>
|
||||||
|
{
|
||||||
|
private readonly TestApi _api = api;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatePaymentIntent_WhenGivenInvalidAmount_ItShouldReturnBadRequest()
|
||||||
|
{
|
||||||
|
var client = _api.CreateClient();
|
||||||
|
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
amount = 0,
|
||||||
|
email = string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Console.WriteLine(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatePaymentIntent_WhenGivenInvalidEmail_ItShouldReturnBadRequest()
|
||||||
|
{
|
||||||
|
var client = _api.CreateClient();
|
||||||
|
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
amount = 5000,
|
||||||
|
email = "invalid-email",
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Console.WriteLine(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatePaymentIntent_WhenGivenValidRequest_ItShouldReturnCreated()
|
||||||
|
{
|
||||||
|
var mockStripeService = new Mock<IStripeService>();
|
||||||
|
|
||||||
|
mockStripeService
|
||||||
|
.Setup(s => s.CreatePaymentIntentAsync(
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.IsAny<decimal>(),
|
||||||
|
It.IsAny<string?>(),
|
||||||
|
It.IsAny<string?>(),
|
||||||
|
It.IsAny<CancellationToken>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync((true, new Intent("pi_12345_secret_67890")));
|
||||||
|
|
||||||
|
var api = _api.WithWebHostBuilder(
|
||||||
|
b => b.ConfigureTestServices(
|
||||||
|
s => s.AddSingleton(mockStripeService.Object)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var client = api.CreateClient();
|
||||||
|
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
name = "Test User",
|
||||||
|
amount = 5000,
|
||||||
|
email = "test@test.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync("/payments/create-intent", request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Console.WriteLine(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user