Merge pull request #3 from StevanFreeborn/stevanfreeborn/feat/integrate-stripe

feat: integrate stripe for payments
This commit is contained in:
Stevan Freeborn
2026-01-04 22:52:56 -06:00
committed by GitHub
50 changed files with 2113 additions and 137 deletions
+6
View File
@@ -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
+7 -3
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -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
+101 -2
View File
@@ -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.
+2
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
NODE_ENV=MODE_PLACEHOLDER
VITE_STRIPE_API_KEY=VITE_STRIPE_API_KEY_PLACEHOLDER
+63 -1
View File
@@ -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

+191 -4
View File
@@ -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;
}
+92 -90
View File
@@ -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>
);
}
+11 -19
View File
@@ -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;
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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
}
}
}
@@ -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");
}
}
}
@@ -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
}
}
}
@@ -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));
}
}
}
+51
View File
@@ -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"
}
}
+2
View File
@@ -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,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);
}
}