Skip to content
Nafis-RohanPublic

About

Spring Boot e-commerce backend with Stripe & bKash checkout — Strategy-pattern payments, JWT auth, DFS + Redis category tree, race-safe stock reduction, Flyway migrations, and a real Postgres/Redis Testcontainers suite.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

E-commerce Ordering & Payment System — Spring Boot

A Spring Boot backend for an online store — user accounts, a product/category catalog, order management, and checkout through two payment providers: Stripe and bKash. It's a feature-for-feature port of an earlier Django REST version, built around a few deliberate design choices (a Strategy pattern for payments, DFS + Redis caching for category recommendations, server-side-only totals) that are explained below, along with the actual problems I ran into while building it and how I solved them.

A small plain HTML/CSS/JS frontend is included and deployed at: https://ecommerce-payment-theta.vercel.app/


1. What this project actually does

  • Users: register/login with JWT, unique email, view your own orders and payments.
  • Products & Categories: full CRUD for admins, a hierarchical category tree, and a "related products" recommendation endpoint.
  • Orders: multi-item orders with server-computed totals, and race-condition-safe stock handling — two people can't oversell the last item in stock.
  • Payments: checkout through Stripe or bKash, with webhook/callback handling that updates order status automatically once a payment actually completes.

2. Architecture

Feature-based package layout — each domain (users, products, orders, payments) is self-contained with its own entities, repositories, services, controllers, DTOs, and tests.

Architecture Diagram

src/main/java/com/nafis/ecommerce/
├── config/          SecurityConfig, CorsConfig, RedisCacheConfig, OpenApiConfig,
│                    JpaAuditingConfig, StripeConfig, RestClientConfig
├── common/          BaseEntity (UUID id, timestamps), ApiError, GlobalExceptionHandler
├── users/           Custom User (email login), Spring Security + JWT, profile
├── products/        Product, Category, DFS recommendations + Redis caching
├── orders/          Order, OrderItem, deterministic totals, safe stock reduction
└── payments/        Payment, Strategy pattern, Stripe + bKash integrations
src/main/resources/
├── application.yml
└── db/migration/    V1__init.sql (schema), V2__seed.sql (admin + sample products)
frontend/            Plain HTML/CSS/JS client (deployed on Vercel)
docs/                Diagrams, payment flow docs, Postman collection

Flyway owns the schema and runs the migrations on startup; Hibernate is set to validate only. Redis backs the category-tree cache and the bKash access token.

3. ERD

ERD

Every table besides users extends a shared BaseEntity — UUID primary key, created_at, updated_at via JPA auditing. users keeps an integer primary key to match the original Django schema. Category is self-referencing (parent), which is what makes the category tree hierarchical. Payment stores provider, a unique transaction_id, status, and the full raw_response from whichever provider handled it, so nothing about a payment's history is ever lost. All money is BigDecimal (numeric(12,2)), never a floating-point type.

4. Payment Flow

Stripe — the backend opens a PaymentIntent, the card is confirmed entirely on the frontend (Stripe Elements, card data never touches this server), and a signed webhook tells the backend the result:

sequenceDiagram
    participant Backend
    participant Stripe

    Backend->>Stripe: Create a PaymentIntent
    Stripe-->>Backend: client_secret + intent id
    Backend->>Backend: Save payment (Pending)

    Stripe->>Backend: "payment_intent.succeeded" ✅
    Backend->>Backend: Verify the signature is really from Stripe
    Backend->>Backend: Reduce stock (locked rows)
    Backend->>Backend: Mark payment Success, order Paid
Loading

bKash — no client SDK; the browser is handed off to bKash's own hosted page, and bKash redirects back once the user approves or declines:

sequenceDiagram
    participant Backend
    participant bKash

    Backend->>bKash: Get access token (cached in Redis)
    bKash-->>Backend: Token
    Backend->>bKash: Create a payment
    bKash-->>Backend: Payment link + ID
    Backend->>Backend: Save payment (Pending)

    bKash->>Backend: User approved/declined it
    alt approved
        Backend->>bKash: Execute the payment
        bKash-->>Backend: Completed ✅
        Backend->>Backend: Reduce stock
        Backend->>Backend: Mark payment Success, order Paid
    else declined
        Backend->>Backend: Mark payment Failed, order Cancelled
    end
Loading

In both cases, stock is only reduced after a payment actually succeeds — never at order creation — so an abandoned or failed checkout never touches inventory.

5. Why these design choices

Strategy pattern for payments (payments/strategy/) — PaymentService injects Map<String, PaymentStrategy>, which Spring populates keyed by bean name ({"stripe": StripeStrategy, "bkash": BkashStrategy} — the Java equivalent of the Django dict). It picks a provider by a simple string lookup, and every strategy implements the same three-method interface (initiate, confirm, verify). Nothing in OrderService or the controllers ever branches on which provider is being used. Adding a third provider later — say, Nagad — means writing one new @Component class and nothing else; zero changes to existing order or checkout logic. Each provider's SDK is also quarantined in a single thin client class (StripeApiClient, BkashApiClient), so the strategies stay easy to unit-test.

DFS + Redis caching for categories (products/CategoryService) — the category tree is a self-referencing hierarchy, so building it wants a traversal. It's done with an explicit Deque used as a stack, not recursion — it reads more clearly and can't blow the call stack on a deep tree. Since that tree rarely changes but gets read constantly (every product page hits it), the serialized tree is @Cacheable in Redis with a 24h TTL and only @CacheEvict-ed when a category is actually created/updated/deleted — so most requests never touch the database for this at all.

Deterministic, server-side totals (orders/OrderService) — calculateTotals always recomputes quantity × price from the database, never trusts a total the client sends (the order DTO is even configured to reject a request body that contains a total). Combined with a pessimistic row lock when checking/reducing stock —

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") UUID id);

the JPA equivalent of Django's select_for_update() — two concurrent checkouts on the last unit of stock can't both succeed. Every product in the order is locked in id order (deadlock avoidance), and the reduction runs in its own REQUIRES_NEW transaction so that if one line comes up short, the rollback stays contained and the shared failPayment helper can still cleanly mark the payment failed and the order cancelled. One checkout wins; the other gets a clean "insufficient stock" error instead of silently overselling.

6. Setting up the payment providers

Stripe (test mode):

  1. Create a free Stripe account, grab your secret key (sk_test_...) from Developers → API keys.
  2. Add a webhook destination pointing at <your-public-url>/api/payments/webhook/stripe/ (this needs a publicly reachable URL — see the ngrok section below), listening for payment_intent.succeeded and payment_intent.payment_failed. Copy its signing secret (whsec_...).
  3. Put both into .env as STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
  4. To push a test PaymentIntent to "succeeded" without a frontend, the Stripe CLI works well: stripe listen --forward-to localhost:8080/api/payments/webhook/stripe/ in one terminal, then stripe payment_intents confirm pi_... --payment-method=pm_card_visa.

bKash (sandbox):

  1. Get sandbox credentials from bKash's developer portal — BKASH_APP_KEY, BKASH_APP_SECRET, BKASH_USERNAME, BKASH_PASSWORD.
  2. BKASH_BASE_URL must be the API domain (https://tokenized.sandbox.bka.sh/v1.2.0-beta), not the customer-facing checkout domain — mixing these up is a very easy first mistake (see below). There's a comment saying exactly this above the property in application.yml.
  3. Checkout returns a bkashURL — redirect the whole browser tab there; bKash redirects back to your callback URL once the user approves/declines on their own hosted page.

Every credential is a placeholder in .env.example. Nothing real is committed.

7. Problems I ran into, and how I solved them

This is the part I actually learned the most from — a payment integration touching two external sandboxes, Docker, and Windows networking surfaces a lot of small, sharp edges that don't show up in a tutorial.

Environment variables don't reload themselves. Editing .env while the backend is already running does nothing — Spring reads it once, at startup, and Docker bakes it into the container when the container is created. I hit this repeatedly early on: I'd fix a key, restart, and the old value would still be in effect. The deeper trap turned out to be docker compose restart vs docker compose up -d --force-recreate — a plain restart just restarts the process inside the same container, whose environment was already fixed when it was first created via env_file. Only a full recreate (or up -d --build) actually re-reads .env. Cost me a fair bit of "why isn't my fix working" time until I found the distinction.

A genuinely confusing one: an orphaned backend process. After fixing an env value, requests still failed with the old error — even though a fresh start confirmed the app had the right key. Turned out an earlier ./mvnw spring-boot:run hadn't actually died; a stale java process was still bound to port 8080 in the background with the old environment, while the terminal I was watching was a completely different, unused process. Found it by checking which PID was actually bound to the port (Get-NetTCPConnection -LocalPort 8080) rather than trusting which terminal I assumed was serving requests.

Windows had silently reserved my dev port. At one point Docker refused to bind port 8080 at all — not because anything else was using it, but because Windows' Hyper-V/WSL2 networking had claimed that exact port range for its own internal use. Restarting the winnat service (the thing that manages those reservations) freed it immediately, without needing a full reboot.

bKash: wrong base URL, then a duplicated env key, then flaky sandbox behavior. First mistake was pointing BKASH_BASE_URL at the customer-facing checkout domain instead of the actual API domain — got a misleading "invalid credentials" error that had nothing to do with the credentials. Second, a copy-paste slip left the key name duplicated inside its own value in .env. After fixing both, I still hit inconsistent results from bKash's sandbox itself — clean failures on their own documented "success" test wallets, and even raw HTTP errors (401/403) on calls that had worked moments earlier. I confirmed this wasn't my integration by cross-checking against Stripe (architecturally identical, worked reliably every time) and by writing a test with the bKash client mocked (@MockBean) that proves the success path is handled correctly whenever bKash does return success — the sandbox's shared, publicly-used credentials being flaky is a known, external limitation, not a code bug.

Crashes instead of clean failures. My first pass at error handling only expected providers to reply with a normal response and a status field to check — it didn't account for a provider call failing outright (a 403, a dropped connection, a timeout). A couple of real bKash sandbox errors exposed this as an actual unhandled exception bubbling to a 500 instead of returning a clean error. Fixed by wrapping every provider call in both initiate() and the confirm/webhook paths, catching the broad RestClientException (covers bad status codes and connection/timeout failures) and Stripe's own StripeException, and consistently routing both through one shared failPayment(...) helper that marks the payment FAILED and the order CANCELLED and persists the raw error. I also gave the bKash RestClient explicit 5s/15s connect/read timeouts so a hanging sandbox can't pin a request thread.

CORS and a self-inflicted follow-up. Deploying the frontend to Vercel meant every request became cross-origin, and free-tier ngrok adds its own wrinkle: it shows a browser-warning interstitial page (plain HTML, not JSON) to any request that looks like it's coming from a browser. The fix — sending a custom ngrok-skip-browser-warning header — then broke CORS preflight itself, since a non-default custom header has to be explicitly allowed via allowedHeaders (CORS_ALLOWED_HEADERS in .env), or the browser blocks the real request before it's even sent. Good reminder that fixing one integration issue can quietly introduce another one layer up.

8. Getting started (backend)

git clone https://github.com/Nafis-Rohan/EcommercePaymentSpring.git
cd EcommercePaymentSpring

cp .env.example .env            # then fill in real values (see .env.example)

docker compose up -d --build    # Postgres + Redis + backend, all three

Backend is now running at http://localhost:8080. Flyway runs the migrations on startup, and V2__seed.sql creates the admin user and sample products automatically — there's no manual migrate/seed step. Interactive API docs are at http://localhost:8080/swagger-ui.html.

A Postman collection is included at docs/postman_collection.json for exercising every endpoint directly, and the published, browsable version is here: https://documenter.getpostman.com/view/47893159/2sBY4SLJMb

To run it without Docker you need JDK 21 plus a local Postgres and Redis, then:

./mvnw spring-boot:run

application.yml reads every setting as ${VAR:default}, so it boots on sensible localhost defaults.

9. Exposing it publicly with ngrok

Stripe's webhook and a deployed frontend both need a real, public URL to reach a backend that's only running on localhost. ngrok creates a secure tunnel to your local port:

ngrok config add-authtoken <your-authtoken-from-the-ngrok-dashboard>
ngrok http 8080

This prints a forwarding line that looks like:

Forwarding    https://detonator-folk-claw.ngrok-free.dev -> http://localhost:8080

That https://...ngrok-free.dev URL is now a real, internet-reachable address for your local backend. Every time ngrok restarts it assigns a new random URL, so each time you need to update:

  • the Stripe webhook destination → https://<random>.ngrok-free.dev/api/payments/webhook/stripe/
  • API_BASE_URL in frontend/js/config.js
  • APP_BASE_URL in .env (so the bKash callbackURL points back through the tunnel)
  • CORS_ALLOWED_ORIGINS in .env — add whatever origin you're serving the frontend from

…and recreate the backend container (up -d --force-recreate), not just restart it, for the .env changes to take effect. Keep ngrok-skip-browser-warning in CORS_ALLOWED_HEADERS — it's already in .env.example, and preflight breaks without it.

10. Frontend

Plain HTML/CSS/JS, no framework, no build step — deployed as a static site on Vercel.

Live: https://ecommerce-payment-theta.vercel.app/

To run it yourself, pointed at your own backend:

cd frontend
python -m http.server 5500

Then open http://localhost:5500/index.html. Before it'll actually talk to your backend, update frontend/js/config.js:

  • API_BASE_URL → your backend's public URL (ngrok, or wherever it's deployed) + /api (the port is 8080 here, not 8000)
  • STRIPE_PUBLISHABLE_KEY → your Stripe publishable key (pk_test_..., safe to expose client-side — never the secret key)

And make sure whatever origin you're serving it from is added to CORS_ALLOWED_ORIGINS in the backend's .env.

11. Tests

mvn test

Needs Docker Desktop running — the suite spins up real Postgres and real Redis via Testcontainers, not H2 or an embedded fake. That's deliberate: the concurrency test depends on genuine SELECT ... FOR UPDATE row locking, which H2's compatibility mode doesn't reproduce faithfully — a green test there would be meaningless — and the cache tests assert on actual Redis keys. Containers are static and reused, so they start once per run.

Covers model/constraint tests (unique slug, unique transaction_id), API tests for auth/orders/payments (including the per-user scoping "authorization leak" checks), a threaded concurrency test proving the stock race-condition fix actually works, and webhook/callback tests for both providers with the provider clients mocked out (@MockBean) — plus a webhook-idempotency test (the same Stripe event delivered twice reduces stock once, not twice).

The Docker image builds with tests skipped on purpose — a container build shouldn't depend on a Docker daemon being reachable from inside itself.

12. Notes on the Docker setup

  • Multi-stage build — a Maven + JDK stage compiles the fat jar; the runtime image is eclipse-temurin:21-jre-alpine with only the jar in it. mvn dependency:go-offline is its own layer, so dependencies re-download only when pom.xml changes.
  • Non-root — the runtime image adds a spring user and runs as it.
  • Healthcheck-gated startup — the backend depends_on Postgres and Redis with condition: service_healthy (pg_isready, redis-cli ping), so it never races an unready database and Flyway doesn't fail on boot.
  • .dockerignore deliberately excludes .env (secrets never belong in an image), target/ and *.jar (the image builds its own), IDE/OS noise, .git/, and docs/ + frontend/ + README.md (not needed at runtime).

About

Spring Boot e-commerce backend with Stripe & bKash checkout — Strategy-pattern payments, JWT auth, DFS + Redis category tree, race-safe stock reduction, Flyway migrations, and a real Postgres/Redis Testcontainers suite.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages