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 ####
|
||||
[*.{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
|
||||
dotnet_separate_import_directive_groups = true
|
||||
dotnet_sort_system_directives_first = true
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Build and push server image
|
||||
run: |
|
||||
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
|
||||
deploy:
|
||||
name: Deploy to server
|
||||
@@ -52,13 +52,17 @@ jobs:
|
||||
script: |
|
||||
TAG=${{ secrets.DOCKERHUB_USERNAME }}/groundsforsupport.stevanfreeborn.com:${{ needs.build.outputs.version }}
|
||||
|
||||
docker stop groundsforsupport.stevanfreeborn.com
|
||||
docker rm groundsforsupport.stevanfreeborn.com
|
||||
docker stop groundsforsupport.stevanfreeborn.com || true
|
||||
docker rm groundsforsupport.stevanfreeborn.com || true
|
||||
|
||||
docker pull $TAG
|
||||
docker run \
|
||||
--restart always \
|
||||
-d \
|
||||
-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 \
|
||||
$TAG
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
appsettings*.json
|
||||
!appsettings.Example.json
|
||||
wwwroot/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
@@ -11,7 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GroundsForSupport.Server.Tests", "GroundsForSupport.Server.Tests", "{56565355-472A-D7A5-BBD6-5A9243A600D2}"
|
||||
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
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
||||
@@ -1,6 +1,105 @@
|
||||
# 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/"]
|
||||
RUN dotnet restore "GroundsForSupport.Server/GroundsForSupport.Server.csproj"
|
||||
|
||||
ARG VITE_STRIPE_API_KEY
|
||||
ENV VITE_STRIPE_API_KEY=$VITE_STRIPE_API_KEY
|
||||
COPY . .
|
||||
WORKDIR "/src/GroundsForSupport.Server"
|
||||
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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.6.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
@@ -1123,6 +1125,29 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
@@ -2851,7 +2876,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@@ -3262,6 +3286,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -3403,6 +3439,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -3620,6 +3665,23 @@
|
||||
"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": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"test": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.6.0",
|
||||
"react": "^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 {
|
||||
background-color: #181818;
|
||||
flex-direction: column;
|
||||
@@ -25,15 +41,17 @@ header .info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header .info p {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #181818;
|
||||
border-radius: 0.25rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 31.25rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
box-shadow: 0 4px 8px #0000001a;
|
||||
}
|
||||
|
||||
@@ -47,6 +65,17 @@ form label {
|
||||
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 {
|
||||
color: #e4e4e4;
|
||||
background-color: #282828;
|
||||
@@ -69,6 +98,15 @@ form input[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
form textarea {
|
||||
color: #e4e4e4;
|
||||
background-color: #282828;
|
||||
border: 1px solid #444;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff6b6b;
|
||||
font-size: 0.875rem;
|
||||
@@ -83,3 +121,152 @@ form button {
|
||||
font-weight: 700;
|
||||
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 { 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() {
|
||||
const [amount, setAmount] = useState<number | ''>('');
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [errors, setErrors] = useState<{ amount?: string; email?: string }>({});
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const clientSecretFromUrl = queryParams.get('payment_intent_client_secret');
|
||||
const [secret, setSecret] = useState<string | undefined>(clientSecretFromUrl ?? undefined);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
async function handleDonationFormSubmit(formData: {
|
||||
name: string;
|
||||
amount: number;
|
||||
message?: string;
|
||||
email?: string;
|
||||
}) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
function handleAmountInput(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
setErrors((prevErrors) => ({ ...prevErrors, amount: undefined }));
|
||||
const value = parseInt(event.target.value);
|
||||
setAmount(isNaN(value) ? '' : value);
|
||||
}
|
||||
try {
|
||||
const url = new URL('/payments/create-intent', import.meta.url);
|
||||
const res = await fetch(url, {
|
||||
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>) {
|
||||
setErrors((prevErrors) => ({ ...prevErrors, email: undefined }));
|
||||
setEmail(event.target.value);
|
||||
}
|
||||
|
||||
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();
|
||||
if (!res.ok) {
|
||||
console.error('Failed to create payment intent');
|
||||
alert('An error occurred while creating the payment. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -72,49 +65,58 @@ function App() {
|
||||
<h1>Stevan Freeborn</h1>
|
||||
<p>
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
>
|
||||
<div className='group'>
|
||||
<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}
|
||||
<section className='payment'>
|
||||
{secret === undefined ? (
|
||||
<PaymentForm
|
||||
onValidSubmit={handleDonationFormSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<span className='error-message'>{errors.amount}</span>
|
||||
</div>
|
||||
<div className='group'>
|
||||
<label htmlFor='email'>Email</label>
|
||||
<input
|
||||
ref={emailInputRef}
|
||||
id='email'
|
||||
type='email'
|
||||
aria-describedby='emailErrorMessage'
|
||||
aria-invalid={errors.email ? 'true' : 'false'}
|
||||
value={email}
|
||||
onInput={handleEmailInput}
|
||||
/>
|
||||
<span
|
||||
id='emailErrorMessage'
|
||||
className='error-message'
|
||||
) : (
|
||||
<Elements
|
||||
options={{
|
||||
clientSecret: secret,
|
||||
loader: 'auto',
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
disableAnimations: true,
|
||||
variables: {
|
||||
colorBackground: '#181818',
|
||||
colorPrimary: '#E4E4E4',
|
||||
colorText: '#E4E4E4',
|
||||
fontFamily: 'CaskaydiaCove NFM, monospace',
|
||||
fontSizeBase: '16px',
|
||||
borderRadius: '0.25rem',
|
||||
colorDanger: '#ff6b6b',
|
||||
},
|
||||
rules: {
|
||||
'.Label': {
|
||||
fontWeight: '700',
|
||||
},
|
||||
'.Input': {
|
||||
backgroundColor: '#282828',
|
||||
border: '1px solid #444',
|
||||
padding: '0.5rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
stripe={stripe}
|
||||
>
|
||||
{errors.email}
|
||||
</span>
|
||||
</div>
|
||||
<button type='submit'>Buy</button>
|
||||
</form>
|
||||
{clientSecretFromUrl ? (
|
||||
<PaymentConfirmationCard clientSecret={clientSecretFromUrl} />
|
||||
) : (
|
||||
<CheckoutForm />
|
||||
)}
|
||||
</Elements>
|
||||
)}
|
||||
</section>
|
||||
{clientSecretFromUrl === null ? <PreviousPaymentsList /> : null}
|
||||
</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-family: 'CaskaydiaCove NFM';
|
||||
src: url('/CaskaydiaCoveNFM-Regular.eot');
|
||||
src: url('/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
|
||||
url('/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
|
||||
url('/CaskaydiaCoveNFM-Regular.woff') format('woff'),
|
||||
url('/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-family: 'CaskaydiaCove NFM';
|
||||
src: url('/CaskaydiaCoveNFM-Regular.eot');
|
||||
src:
|
||||
url('/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
|
||||
url('/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
|
||||
url('/CaskaydiaCoveNFM-Regular.woff') format('woff'),
|
||||
url('/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* CSS Reset */
|
||||
@@ -56,7 +57,7 @@ table {
|
||||
|
||||
:root {
|
||||
--bg-color: #323232;
|
||||
--text-color: #E4E4E4;
|
||||
--text-color: #e4e4e4;
|
||||
|
||||
font-size: 16px;
|
||||
font-family: 'CaskaydiaCove NFM', monospace;
|
||||
@@ -69,12 +70,3 @@ body {
|
||||
background-color: var(--bg-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 { render } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -7,7 +7,7 @@ import userEvent, { type UserEvent } from '@testing-library/user-event';
|
||||
describe('profile info', () => {
|
||||
test('it should display a profile image', () => {
|
||||
const screen = render(<App />);
|
||||
|
||||
|
||||
const profileImage = screen.getByAltText('Profile picture of Stevan Freeborn');
|
||||
|
||||
expect(profileImage).toBeInTheDocument();
|
||||
@@ -25,8 +25,36 @@ describe('profile info', () => {
|
||||
describe('form', () => {
|
||||
let user: UserEvent;
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
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', () => {
|
||||
@@ -83,8 +111,6 @@ describe('form', () => {
|
||||
});
|
||||
|
||||
test('it should allow form to be submitted without email', async () => {
|
||||
const windowSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||
|
||||
const screen = render(<App />);
|
||||
|
||||
const amountInput = screen.getByLabelText('Amount');
|
||||
@@ -92,8 +118,6 @@ describe('form', () => {
|
||||
|
||||
await user.type(amountInput, '10');
|
||||
await user.click(submitButton);
|
||||
|
||||
expect(windowSpy).toHaveBeenCalledWith('Form is valid! Amount: 10, Email: ');
|
||||
});
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<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);
|
||||
|
||||
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();
|
||||
|
||||
app.UseExceptionHandler();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
if (app.Environment.IsProduction())
|
||||
{
|
||||
app.UseForwardedHeaders();
|
||||
app.UseRateLimiter();
|
||||
}
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.UseStatusCodePages();
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseHsts();
|
||||
|
||||
app.MapCreatePaymentIntentEndpoint();
|
||||
app.MapGetPaymentsEndpoint();
|
||||
app.MapEventsEndpoint();
|
||||
|
||||
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>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>GroundsForSupport.API.Tests</RootNamespace>
|
||||
<RootNamespace>GroundsForSupport.Server.Tests</RootNamespace>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="AwesomeAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -26,7 +27,9 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.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.runner.visualstudio" Version="3.1.5">
|
||||
<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