Order‐Payment Flow - VittorioDeMarzi/hero-beans GitHub Wiki
The CheckoutController
is responsible for the overall order and payment process. Refund features will be added here in the future.
Orders and payments are bundled together as a Checkout session (such as PaymentIntent
).
During the checkout process:
- Creates
Order
fromCart
and reduces stock - Creates
PaymentIntent
viaPaymentService
- Returns payment details (clientSecret, orderId, amount)
- Runs in single transaction - auto rollback on failure
@PostMapping("/start")
fun start(
@LoginMember member: MemberDto,
@Valid @RequestBody request: StartCheckoutRequest,
): ResponseEntity<StartCheckoutResponse> {
val response = checkoutService.startCheckout(member, request)
return ResponseEntity.ok(response)
}
Payment confirmation is handled through the finalize process. During finalization:
- Confirms PaymentIntent with payment provider
- Marks Order as paid/failed/cancelled based on payment status
- Rollbacks stock if payment fails
- Runs in single transaction - ensures data consistency
@PostMapping("/finalize")
fun finalize(
@LoginMember member: MemberDto,
@Valid @RequestBody request: FinalizePaymentRequest,
): ResponseEntity<FinalizePaymentResponse> {
val response = checkoutService.finalizeCheckout(member, request)
return ResponseEntity.ok(response)
}
-
Stock reservation: Prevents "out of stock" errors during payment by securing inventory at
/start
- Clear rollback: Payment failure rollback handling becomes clearer - separating Order creation and Payment processing
- Concept separation: Order records "purchase intent" first, Payment handles "actual money transfer" later
- Extensibility: Separated concepts make it easier to handle refunds, partial payments, etc. in the future
- User experience: Prevents race conditions where other customers purchase the same product while payment UI is loading
Our project scale is small and target user count is low, so race conditions are unlikely to occur frequently. However, the most important aspects are money and inventory management, and payment and inventory management require minimum protection:
- Payment failures must rollback
- Payment processes must be recorded
- Multiple users should not "write" data simultaneously through "concurrent orders"
For a safe checkout process, I decided to use @Lock
.
The key decision points were:
- Safety over speed - even if it takes longer (stability was the highest priority)
- Ease of use - optimizing locks is not a high priority
My proposal is to use @Lock(LockModeType.PESSIMISTIC_WRITE)
to fetch List<Option>
containing stock, preventing user activity while those Options are being written.
interface OptionJpaRepository : JpaRepository<PackageOption, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM PackageOption o WHERE o.id IN :ids ORDER BY o.id")
fun findByIdsWithLock(ids: List<Long>): List<PackageOption>?
}
These locked options are first compared with Order items' options for basic validation, and if no major issues exist, quantities are decreased. This entire process occurs within a single transaction, so it rolls back automatically on failure.
@Transactional
fun startCheckout(
memberDto: MemberDto,
request: StartCheckoutRequest,
): StartCheckoutResponse {
val cart = cartService.getCartForOrder(memberDto.id)
// @Lock(LockModeType.PESSIMISTIC_WRITE) Option Repository from the line below
val order = orderService.processOrderWithStockReduction(cart)
val paymentIntent = paymentService.createPaymentIntent(request, order.totalAmount)
val payment = paymentService.createPayment(request, paymentIntent, order)
return StartCheckoutResponse( ... )
}
Maintaining long transactions while locks exist is not healthy. Having other users unable to write during the "order processing" period of several tens of seconds is not ideal...
Ideally, transactions should be kept as small and short as possible.
However, let's reconsider our premise. We operate a small-scale business where slight delays can be tolerated. If the most important thing is "smooth payment processing," I believe transaction scope should encompass the each entire checkout process...
[click checkout] → /start → @Transactional →
create Order from Cart → create PaymentIntent from /start's request →
create Payment → response to client → [Payment UI handle event with API]
[Payment UI handle event with API] → /finalize → @Transactional →
find Payment data with PaymentIntent id from /finalize request →
Order Complete or Failed
to ensure data consistency and payment reliability, even at the cost of some performance.