Skip to content

Commit f877747

Browse files
author
SAY-5
committed
Initial commit: full PayFlow implementation
- Spring Boot 3 + Java 21 backend with JPA domain model covering Merchant, Customer, PaymentIntent, Charge, Refund, WebhookEvent, IdempotencyKey, AuditLog. Flyway migrations (Postgres prod, H2 test fixture). - Idempotency: per-(merchant, key) reservation + response replay, REQUIRES_NEW transaction isolation for the reserve/complete hop, 409 Retry-After on in-flight collisions, 422 on mismatched bodies. - Stripe-compatible webhook verification: HMAC-SHA256 with constant- time compare, 5-min replay window, same-event-id returns duplicate without reprocessing. Signature scheme is the real Stripe spec so the Stripe CLI replays valid events. - Payment lifecycle: create → confirm via gateway → state transitions (requires_confirmation → processing → succeeded | failed | canceled), with a deterministic in-memory gateway for tests/demo. - Refund flow: partial + full with cumulative check, refuses over-refunds. - Audit log as append-only table with REQUIRES_NEW propagation so it survives rollbacks. - JWT auth (HS256), CORS-bounded, Spring Security integrated. - Frontend: React 18 + Vite + TS admin console — deep navy ground, warm bronze accent, Fraunces display + IBM Plex Sans/Mono. Transactions table, detail pane with refund form, stats cards, single-screen charge form. - Tests: 15 JUnit 5 / SpringBoot tests — HashingTest (known vectors), StripeSignatureTest (sign/verify/tamper/replay), PaymentFlowIntegrationTest (7 MockMvc end-to-end cases incl. idempotency replay/mismatch/ missing key), WebhookIngestTest (invalid sig, valid sig, duplicate). - Dockerfile (multi-stage, non-root uid 10001, healthcheck), compose file (Postgres 16 + payflow).
0 parents  commit f877747

67 files changed

Lines changed: 6388 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: ci
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
7+
jobs:
8+
backend:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-java@v4
13+
with:
14+
distribution: temurin
15+
java-version: "21"
16+
cache: maven
17+
- name: Compile + test
18+
run: mvn -B -ntp verify
19+
20+
frontend:
21+
runs-on: ubuntu-latest
22+
defaults:
23+
run:
24+
working-directory: frontend
25+
steps:
26+
- uses: actions/checkout@v4
27+
- uses: actions/setup-node@v4
28+
with:
29+
node-version: "22.x"
30+
cache: npm
31+
cache-dependency-path: frontend/package-lock.json
32+
- run: npm ci
33+
- run: npm run build

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
target/
2+
*.class
3+
*.jar
4+
.idea/
5+
*.iml
6+
.vscode/
7+
.DS_Store
8+
node_modules/
9+
frontend/dist/
10+
frontend/.vite/
11+
*.db
12+
*.db-journal
13+
.env
14+
.env.*
15+
HELP.md

ARCHITECTURE.md

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
# PayFlow Architecture
2+
3+
## Overview
4+
5+
PayFlow is a payment-processing API. It ingests charge requests, calls a
6+
payment provider (Stripe in this reference), reconciles webhook events,
7+
and exposes a refund workflow. Correctness-critical properties it
8+
guarantees:
9+
10+
1. **Exactly-once charge** — the same client-supplied idempotency key
11+
always produces the same outcome, even under retry storms.
12+
2. **Reconciled state** — every transaction row's status reflects the
13+
last authoritative webhook for that payment intent.
14+
3. **Immutable audit trail** — every state-changing request is recorded
15+
in an append-only `audit_log` table, including the request body hash
16+
and the actor.
17+
18+
Stack:
19+
20+
| Layer | Tech |
21+
|-------------|-------------------------------------------------------|
22+
| Service | Java 21 · Spring Boot 3.x · Spring Web · Spring Data JPA |
23+
| Persistence | PostgreSQL 15 · Flyway migrations |
24+
| Auth | JWT (HS256) issued by `/auth/token` |
25+
| Tests | JUnit 5 · Testcontainers (PG) · RestAssured |
26+
| Frontend | React 18 · Vite · TypeScript |
27+
| Ops | Docker multi-stage · docker-compose (svc + db) |
28+
29+
## Domain model
30+
31+
```
32+
Merchant ── issuer of API keys, has many PaymentIntents
33+
Customer ── end-user; optional PaymentMethod tokens
34+
PaymentIntent ── lifecycle: requires_confirmation → processing → succeeded | failed | canceled
35+
Charge ── one row per attempt against a provider (1..N per intent)
36+
Refund ── lifecycle: pending → succeeded | failed
37+
WebhookEvent ── raw provider events, dedup'd on provider event id
38+
IdempotencyKey── (merchant_id, key) → response_hash + http_status + body
39+
AuditLog ── append-only audit rows
40+
```
41+
42+
### Why PaymentIntent + Charge split
43+
44+
A single "intent" can span multiple charge attempts (retry after a
45+
timeout, reauthorize, 3DS challenge). We store the intent as the
46+
business-level object clients reason about, and each concrete attempt as
47+
a separate row — so the full history is on disk for dispute resolution.
48+
49+
## Idempotency
50+
51+
Clients supply `Idempotency-Key: <opaque>` on every POST. The first
52+
request with a given key:
53+
54+
1. Opens a transaction. Inserts `(merchant_id, key)` into
55+
`idempotency_keys` with a placeholder. Unique constraint prevents
56+
concurrent duplicates.
57+
2. Runs the request handler.
58+
3. Updates the row with the response body, status code, and the SHA-256
59+
of the canonicalized request body.
60+
4. Commits.
61+
62+
A second request with the same key:
63+
- If the key exists and is complete: returns the stored response.
64+
- If the key exists but is incomplete (in-flight): returns 409 Conflict
65+
with `Retry-After: 5`.
66+
- If the key exists and the stored body hash differs from the new
67+
request's hash: returns 422 Unprocessable Entity (catch obvious
68+
programmer errors — a client re-using the same key for a different
69+
body).
70+
71+
Keys are scoped per-merchant to avoid cross-tenant collisions.
72+
Retention: 7 days (configurable via `payflow.idempotency.ttl-days`).
73+
74+
## Stripe webhook ingestion
75+
76+
Endpoint: `POST /webhooks/stripe`.
77+
78+
1. Read raw body (kept as bytes for signature verification).
79+
2. Verify `Stripe-Signature` header against the payload + shared secret
80+
using the HMAC-SHA256 scheme documented by Stripe. Reject 5-min-old
81+
signatures.
82+
3. Parse the event. Check `webhook_events` for the `provider_event_id`.
83+
If present, return 200 immediately (replay).
84+
4. Insert the event row, open a transaction, and dispatch to a handler
85+
by `event.type`:
86+
- `payment_intent.succeeded` / `.failed` / `.canceled` → update
87+
`payment_intents.status` + append to `charges`.
88+
- `charge.refunded` → update the matching `refunds` row.
89+
- Anything else → insert as "observed but unhandled".
90+
5. If any step after signature verification fails, we still return 200
91+
(because 4xx/5xx triggers Stripe's retry with exponential backoff),
92+
but log the event with `status = 'error'` so a reconciliation job can
93+
retry manually. This preserves at-least-once delivery without
94+
turning into exponential pile-up on our side.
95+
96+
## HTTP API
97+
98+
```
99+
POST /v1/payment-intents create + confirm
100+
GET /v1/payment-intents/:id fetch one
101+
GET /v1/payment-intents?status=&... list
102+
POST /v1/payment-intents/:id/cancel cancel if not yet processing
103+
POST /v1/refunds issue refund (partial or full)
104+
GET /v1/refunds/:id
105+
POST /webhooks/stripe Stripe posts here
106+
POST /auth/token exchange API key for short-lived JWT
107+
GET /healthz
108+
```
109+
110+
All create/update endpoints require `Authorization: Bearer <JWT>` + an
111+
`Idempotency-Key` header. Validation errors return RFC 7807 problem
112+
JSON.
113+
114+
## Schema (Flyway: `V1__initial_schema.sql`)
115+
116+
Abbreviated:
117+
118+
```sql
119+
CREATE TABLE merchants (
120+
id UUID PRIMARY KEY,
121+
name TEXT NOT NULL,
122+
api_key_hash TEXT NOT NULL UNIQUE,
123+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
124+
);
125+
126+
CREATE TABLE customers (
127+
id UUID PRIMARY KEY,
128+
merchant_id UUID NOT NULL REFERENCES merchants(id),
129+
email TEXT,
130+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
131+
);
132+
133+
CREATE TABLE payment_intents (
134+
id UUID PRIMARY KEY,
135+
merchant_id UUID NOT NULL REFERENCES merchants(id),
136+
customer_id UUID REFERENCES customers(id),
137+
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
138+
currency CHAR(3) NOT NULL,
139+
status TEXT NOT NULL,
140+
provider TEXT NOT NULL,
141+
provider_id TEXT UNIQUE,
142+
description TEXT,
143+
metadata_json JSONB NOT NULL DEFAULT '{}'::JSONB,
144+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
145+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
146+
);
147+
148+
CREATE TABLE charges (
149+
id UUID PRIMARY KEY,
150+
intent_id UUID NOT NULL REFERENCES payment_intents(id),
151+
attempt_no INT NOT NULL,
152+
status TEXT NOT NULL,
153+
provider_charge_id TEXT UNIQUE,
154+
failure_code TEXT,
155+
failure_message TEXT,
156+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
157+
UNIQUE (intent_id, attempt_no)
158+
);
159+
160+
CREATE TABLE refunds (
161+
id UUID PRIMARY KEY,
162+
intent_id UUID NOT NULL REFERENCES payment_intents(id),
163+
amount_cents BIGINT NOT NULL CHECK (amount_cents > 0),
164+
status TEXT NOT NULL,
165+
reason TEXT,
166+
provider_refund_id TEXT UNIQUE,
167+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
168+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
169+
);
170+
171+
CREATE TABLE idempotency_keys (
172+
merchant_id UUID NOT NULL REFERENCES merchants(id),
173+
key TEXT NOT NULL,
174+
request_hash TEXT NOT NULL,
175+
response_status INT,
176+
response_body JSONB,
177+
completed_at TIMESTAMPTZ,
178+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
179+
PRIMARY KEY (merchant_id, key)
180+
);
181+
182+
CREATE TABLE webhook_events (
183+
id UUID PRIMARY KEY,
184+
provider TEXT NOT NULL,
185+
provider_event_id TEXT NOT NULL,
186+
event_type TEXT NOT NULL,
187+
payload JSONB NOT NULL,
188+
status TEXT NOT NULL,
189+
error TEXT,
190+
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
191+
UNIQUE (provider, provider_event_id)
192+
);
193+
194+
CREATE TABLE audit_log (
195+
id BIGSERIAL PRIMARY KEY,
196+
actor_type TEXT NOT NULL, -- 'merchant' | 'system' | 'webhook'
197+
actor_id TEXT,
198+
action TEXT NOT NULL,
199+
resource_type TEXT NOT NULL,
200+
resource_id TEXT,
201+
request_hash TEXT,
202+
result TEXT NOT NULL, -- 'ok' | 'denied' | 'error'
203+
detail JSONB,
204+
at TIMESTAMPTZ NOT NULL DEFAULT now()
205+
);
206+
```
207+
208+
Indexes on `payment_intents(merchant_id, created_at DESC)`, `charges(intent_id)`,
209+
`audit_log(resource_type, resource_id)`.
210+
211+
## Frontend
212+
213+
A small React + Vite admin console. Not the consumer-facing checkout —
214+
that's scope creep for a reference project. The console shows:
215+
216+
- Transactions list (filter by status, currency, date range)
217+
- Transaction detail (intent + charges + refunds + webhook timeline + audit)
218+
- Issue refund form
219+
- Usage stats panel (total succeeded, failed, refunded this month)
220+
221+
Design language: navy + warm bronze + editorial serif for headings.
222+
Tabular numerics for money.
223+
224+
## Testing
225+
226+
- **Unit**: pure service tests for idempotency key manager, signature
227+
verifier, state machine transitions. JUnit 5.
228+
- **Integration**: Spring Boot Test with Testcontainers PostgreSQL.
229+
Full HTTP stack up through the controllers, real DB, mocked Stripe
230+
client.
231+
- **Property-ish**: random request body + key reuse test asserting same
232+
key → same response, different key → always executes.
233+
- **Webhook replay**: send the same event twice, assert idempotent.
234+
235+
Target coverage: 85% on service layer, 75% overall.
236+
237+
## Security notes
238+
239+
- API keys are never stored raw — only SHA-256 hashed with a constant-time
240+
compare at verification.
241+
- JWT secret ≥ 32 bytes, mandatory in prod.
242+
- Webhook signature: HMAC-SHA256 with the shared secret, constant-time
243+
comparison, 5-minute replay window.
244+
- All DB queries use parameterized JPA/JDBC — no string concatenation.
245+
- Amounts are stored as `BIGINT cents`. Never float, never decimal
246+
with ambiguous precision.
247+
248+
## Non-goals
249+
250+
- Real Stripe integration in this reference — a mocked Stripe client
251+
with injectable fixtures. The webhook verifier is real Stripe-compatible.
252+
- Card data handling / PCI scope — we never see raw PANs; the frontend
253+
uses Stripe Elements for tokenization (documented but not hooked up
254+
in the demo).
255+
- Multi-currency FX.

Dockerfile

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# syntax=docker/dockerfile:1.7
2+
3+
# ---- builder ---------------------------------------------------------------
4+
FROM maven:3.9-eclipse-temurin-21 AS builder
5+
WORKDIR /build
6+
COPY pom.xml ./
7+
RUN mvn -B dependency:go-offline -q
8+
COPY src ./src
9+
RUN mvn -B -q -DskipTests package \
10+
&& mv target/payflow-*.jar /build/app.jar
11+
12+
# ---- runner ----------------------------------------------------------------
13+
FROM eclipse-temurin:21-jre-noble AS runner
14+
WORKDIR /app
15+
16+
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
17+
18+
RUN useradd --system --uid 10001 --no-create-home payflow \
19+
&& chown -R payflow:payflow /app
20+
USER payflow
21+
22+
COPY --from=builder --chown=payflow:payflow /build/app.jar /app/app.jar
23+
24+
EXPOSE 8080
25+
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
26+
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
27+
28+
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 SAY-5
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

0 commit comments

Comments
 (0)