{
  "slug": "travel-agency",
  "title": "Travel agency management system",
  "label": "travel agency",
  "kind": "personal",
  "tagline": "A Spring Boot app on MariaDB that started as a console and grew a web interface.",
  "summary": "A Spring Boot app on MariaDB that started as a console and grew a web interface, with session login and BCrypt, PayPal payments and PDF tickets with QR codes.",
  "stack": [
    "Java 25",
    "Spring Boot 3.5",
    "Spring Security",
    "Spring Data JPA",
    "MariaDB"
  ],
  "status": "completed",
  "lead": "Search for a trip, book it for several people, pay with PayPal and download the ticket: the management system of a small travel agency, built with Spring Boot.",
  "facts": [
    {
      "name": "payments",
      "value": "PayPal, tested end to end in the sandbox",
      "href": null
    },
    {
      "name": "code",
      "value": "private repository",
      "href": null
    }
  ],
  "caseStudy": {
    "sections": [
      {
        "id": "starting-point",
        "title": "Starting point",
        "paragraphs": [
          "My first project was a library in plain Java, with the books in a text file. The travel agency keeps the same split into entities and services, in a different domain.",
          "The goal was to go the whole way with a Spring application: from in-memory data to a relational database, from the console to the web, through to payment and the ticket. The choices and the backend are mine; the HTML pages were generated by AI."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "persistence",
        "title": "Persistence, one step at a time",
        "paragraphs": [
          "After the library I got to JPA one step at a time, each step fixing a problem of the one before."
        ],
        "body": {
          "kind": "steps",
          "flow": {
            "label": null,
            "steps": [
              {
                "title": "The library",
                "code": null,
                "text": "plain Java, books in a text file",
                "date": "2025-05",
                "outcome": "where it started",
                "outside": "another project"
              },
              {
                "title": "In-memory lists",
                "code": null,
                "text": "trips and bookings in in-memory lists",
                "date": "2025-10-16",
                "outcome": "lost on restart",
                "outside": null
              },
              {
                "title": "Hand-written JDBC",
                "code": null,
                "text": "SQL in the code, each service opens its own connection",
                "date": "2025-10-24",
                "outcome": "data survives a restart",
                "outside": null
              },
              {
                "title": "DataSource and HikariCP",
                "code": null,
                "text": "Spring injects a pooled DataSource",
                "date": "2025-10-29",
                "outcome": "connections returned to the pool and reused",
                "outside": null
              },
              {
                "title": "JPA and Spring Data",
                "code": null,
                "text": "entities and repositories",
                "date": "2025-12-01",
                "outcome": "the repeated JDBC code goes away",
                "outside": null
              }
            ],
            "caption": null,
            "base": {
              "name": "MariaDB",
              "text": "the same database, a different way in",
              "from": 3
            }
          },
          "quiet": null
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "architecture",
        "title": "How it is built",
        "paragraphs": [
          "The console came first, with the rules already in the services. The web was added on top of the same services, in the same process, without touching the booking logic."
        ],
        "body": {
          "kind": "layerStack",
          "columns": [
            [
              {
                "name": "Console",
                "text": "the original interface",
                "short": null
              },
              {
                "name": "Menus",
                "text": "presentation only, no rules",
                "short": "no rules"
              }
            ],
            [
              {
                "name": "Browser",
                "text": "Thymeleaf pages and JavaScript",
                "short": "Thymeleaf and JavaScript"
              },
              {
                "name": "Spring Security",
                "text": "form login and session",
                "short": "form login, session"
              },
              {
                "name": "Controllers",
                "text": "thin: pages and JSON",
                "short": "thin"
              }
            ]
          ],
          "core": {
            "name": "services",
            "text": "rules and transactions; only DTOs go up",
            "short": null,
            "parts": [
              {
                "name": "users",
                "focus": false
              },
              {
                "name": "trips",
                "focus": false
              },
              {
                "name": "bookings",
                "focus": true
              },
              {
                "name": "tickets",
                "focus": false
              },
              {
                "name": "payments",
                "focus": false
              },
              {
                "name": "email",
                "focus": false
              },
              {
                "name": "tokens",
                "focus": false
              }
            ]
          },
          "below": [
            {
              "name": "Repositories",
              "text": "Spring Data JPA",
              "short": null
            },
            {
              "name": "HikariCP → MariaDB",
              "text": "one database for the console and the web",
              "short": null
            }
          ],
          "aside": {
            "caption": "the services also talk to",
            "services": [
              {
                "name": "PayPal",
                "text": "REST API, sandbox",
                "short": "REST API"
              },
              {
                "name": "Gmail",
                "text": "confirmation and reset emails",
                "short": "email"
              }
            ]
          }
        },
        "note": "The console menu and the PayPal capture create a booking through the same service method.",
        "subsection": null
      },
      {
        "id": "purchase-flow",
        "title": "From search to ticket",
        "paragraphs": [
          "The order and the capture go through the server, with its credentials, and on the web the booking is created only when PayPal tells the server the payment is complete."
        ],
        "body": {
          "kind": "sequenceFlow",
          "participants": [
            {
              "name": "Browser",
              "role": "Thymeleaf and JavaScript",
              "short": "browser"
            },
            {
              "name": "Spring Boot",
              "role": "controllers and services",
              "short": "server"
            },
            {
              "name": "PayPal",
              "role": "REST API, sandbox",
              "short": "PayPal"
            },
            {
              "name": "MariaDB",
              "role": "via Spring Data JPA",
              "short": "MariaDB"
            }
          ],
          "steps": [
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "search: destination, price, seats",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                },
                {
                  "from": 1,
                  "to": 3,
                  "label": {
                    "text": "one query with optional filters",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                }
              ],
              "summary": {
                "from": 0,
                "to": 1,
                "title": {
                  "text": "search",
                  "code": false
                },
                "text": "destination, price and seats, in one query with optional filters"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "opens the confirmation",
                    "code": false
                  },
                  "note": "price, discount, number of people",
                  "lit": false
                }
              ],
              "summary": {
                "from": 0,
                "to": 1,
                "title": {
                  "text": "confirmation",
                  "code": false
                },
                "text": "price, discount, number of people"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "asks for an order",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                },
                {
                  "from": 1,
                  "to": 2,
                  "label": {
                    "text": "creates the order",
                    "code": false
                  },
                  "note": "with the server’s credentials",
                  "lit": false
                }
              ],
              "summary": {
                "from": 1,
                "to": 2,
                "title": {
                  "text": "creates the order",
                  "code": false
                },
                "text": "with the server’s credentials"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 2,
                  "label": {
                    "text": "the customer approves in the PayPal popup",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                }
              ],
              "summary": {
                "from": 0,
                "to": 2,
                "title": {
                  "text": "the customer approves",
                  "code": false
                },
                "text": "in the PayPal popup"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "asks for the capture",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                },
                {
                  "from": 1,
                  "to": 2,
                  "label": {
                    "text": "capture → COMPLETED",
                    "code": true
                  },
                  "note": null,
                  "lit": true
                }
              ],
              "summary": {
                "from": 1,
                "to": 2,
                "title": {
                  "text": "capture → COMPLETED",
                  "code": true
                },
                "text": "requested by the browser"
              }
            },
            {
              "messages": [
                {
                  "from": 1,
                  "to": 3,
                  "label": {
                    "text": "booking saved, seats taken",
                    "code": false
                  },
                  "note": "a single transaction",
                  "lit": true
                }
              ],
              "summary": {
                "from": 1,
                "to": 3,
                "title": {
                  "text": "booking and seats",
                  "code": false
                },
                "text": "a single transaction"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "downloads the ticket",
                    "code": false
                  },
                  "note": "PDF with a QR code, only if the booking is yours",
                  "lit": false
                }
              ],
              "summary": {
                "from": 0,
                "to": 1,
                "title": {
                  "text": "the ticket",
                  "code": false
                },
                "text": "PDF with a QR code, only if the booking is yours"
              }
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "data-model",
        "title": "The data model",
        "paragraphs": [
          "Only the columns that reflect a choice: seats as a counter on the trip, the price frozen in the booking, the token that expires and works once."
        ],
        "body": {
          "kind": "dataModel",
          "tables": [
            {
              "name": "users",
              "columns": [
                {
                  "name": "email",
                  "note": null,
                  "key": false
                },
                {
                  "name": "password",
                  "note": "hash only",
                  "key": true
                },
                {
                  "name": "enabled",
                  "note": "after email confirmation",
                  "key": true
                }
              ],
              "focus": false
            },
            {
              "name": "booked_trips",
              "columns": [
                {
                  "name": "people_count",
                  "note": "people",
                  "key": true
                },
                {
                  "name": "final_price",
                  "note": "total paid, frozen",
                  "key": true
                },
                {
                  "name": "applied_discount",
                  "note": "discount at the time, frozen",
                  "key": true
                }
              ],
              "focus": true
            },
            {
              "name": "trips",
              "columns": [
                {
                  "name": "price",
                  "note": "today’s price",
                  "key": false
                },
                {
                  "name": "discount_percentage",
                  "note": "today’s discount",
                  "key": false
                },
                {
                  "name": "available_seats",
                  "note": "seat counter",
                  "key": true
                }
              ],
              "focus": false
            },
            {
              "name": "followed_trips",
              "columns": [
                {
                  "name": "user_id",
                  "note": null,
                  "key": false
                },
                {
                  "name": "trip_id",
                  "note": null,
                  "key": false
                }
              ],
              "focus": false
            },
            {
              "name": "confirmation_token",
              "columns": [
                {
                  "name": "token",
                  "note": "random",
                  "key": true
                },
                {
                  "name": "expires_at",
                  "note": "short expiry",
                  "key": true
                },
                {
                  "name": "confirmed_at",
                  "note": "works once",
                  "key": true
                },
                {
                  "name": "token_type",
                  "note": "confirmation or reset",
                  "key": true
                }
              ],
              "focus": false
            }
          ],
          "relations": [
            {
              "from": "users",
              "to": "booked_trips",
              "verb": "books",
              "through": null
            },
            {
              "from": "trips",
              "to": "booked_trips",
              "verb": "is booked in",
              "through": null
            },
            {
              "from": "users",
              "to": "trips",
              "verb": "follows",
              "through": "followed_trips"
            },
            {
              "from": "users",
              "to": "confirmation_token",
              "verb": "receives",
              "through": null
            }
          ],
          "around": "and around users"
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "decisions",
        "title": "Decisions",
        "paragraphs": [],
        "body": {
          "kind": "decisions",
          "records": [
            {
              "number": 1,
              "status": "accepted",
              "title": "A session, not a token",
              "context": "The HTML pages are rendered by the same server with Thymeleaf: there is no separate frontend.",
              "decision": "Login through Spring Security’s form and a server-side session, not a JWT.",
              "consequences": "Spring Security handles login, logout and redirects with no extra code, and logging out really ends the session. In the driving school system, with a separate client, I used a JWT instead."
            },
            {
              "number": 2,
              "status": "accepted",
              "title": "The price is frozen in the booking",
              "context": "A trip’s price and discount can change after a purchase. Seats are not assigned: all that matters is how many are left.",
              "decision": "The booking stores the total paid, the discount applied at that moment and the number of people. The trip keeps a counter of free seats.",
              "consequences": "The history stays what the customer paid, even if the agency changes the discount later. One row per seat wasn’t needed: with no assigned seats, a counter is enough."
            },
            {
              "number": 3,
              "status": "accepted",
              "title": "A database token for confirmation and reset",
              "context": "Two moments need proof that an email belongs to whoever is using it: sign-up and a forgotten password.",
              "decision": "One table: a random token that expires soon and works only once, with a type, confirmation or reset. The account stays disabled until the email is confirmed.",
              "consequences": "The token is invalidated by writing a date, which a signed stateless token can’t do without a revocation list. The type stops a confirmation link from changing a password, and password reset came later as one more type, not another table."
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "passwords",
        "title": "Passwords",
        "paragraphs": [],
        "body": {
          "kind": "beforeAfter",
          "problems": [
            {
              "title": "From plain text to a hash",
              "text": "At first passwords were stored in plain text, and login compared them as they were. Then I studied how hashing works and what BCrypt does, and switched to it.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "plain text",
                  "text": "stored and compared as typed"
                },
                "after": {
                  "value": "BCrypt",
                  "text": "only the hash in the database, checked at login"
                }
              }
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "status",
        "title": "Status",
        "paragraphs": [
          "Complete, with PayPal in the sandbox. The next project was the driving school management system."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "lessons",
        "title": "What I learned",
        "paragraphs": [
          "Writing JDBC, connection handling and row-to-object mapping by hand before moving to JPA showed me what an ORM does for me, and where to look when it does something I don’t expect. With PayPal I saw that a payment isn’t a single call but an order that goes through several states, and that the booking must come only from a confirmed capture.",
          "If I built it again, I would hold the seats before asking for the payment: today they are taken after the capture, and if they run out in between, the customer has paid for a booking that never gets created."
        ],
        "body": null,
        "note": null,
        "subsection": null
      }
    ]
  },
  "_links": {
    "self": {
      "href": "/en/projects/travel-agency"
    },
    "alternate": {
      "href": "/it/projects/travel-agency",
      "hreflang": "it"
    },
    "up": {
      "href": "/en/projects"
    }
  }
}
