diff --git a/docs/LOCAL_PAYMENT_STUB.md b/docs/LOCAL_PAYMENT_STUB.md new file mode 100644 index 0000000..b34071d --- /dev/null +++ b/docs/LOCAL_PAYMENT_STUB.md @@ -0,0 +1,421 @@ +# Local Payment Stub for Development + +This document explains how to use the local payment stub for testing payment flows without connecting to the real Mollie API. + +## Overview + +The local payment stub (`LocalCheckoutProvider`) provides a fully functional payment system for local development and testing. It simulates the entire Mollie payment flow including: + +- Creating payments and checkout URLs +- Managing payment statuses (PAID, CANCELLED, REFUNDED, ONGOING) +- Triggering webhooks automatically +- Providing a simulated checkout page +- Storing payment data in memory for inspection + +## Quick Start + +### 1. Configuration + +The stub is automatically enabled when the Mollie API key is **not** configured. This is the default in `application-local.yml`: + +```yaml +organization: + api-key: + mollie: # Leave blank for local stub +``` + +If you want to use the real Mollie API in local development, set your test API key: + +```yaml +organization: + api-key: + mollie: "test_xxxxxxxxxxxxxxxxxx" +``` + +### 2. Start the Application + +```bash +# Make sure your local environment is running (database, etc.) +mvn spring-boot:run -Dspring-boot.run.profiles=local +``` + +You should see in the logs: +``` +🎭 LocalCheckoutProvider initialized - Using LOCAL payment stub for development +💡 Tip: Use LocalPaymentController endpoints to manage test payments +``` + +### 3. Testing Payment Flow + +#### Option A: Automatic Flow (Simulated Checkout) + +1. Create a registration (event/activity/membership) through the normal API +2. You'll receive a checkout URL like: `http://localhost:8080/api/local-payments/checkout?paymentId=tr_local_xxx` +3. Open this URL in your browser to see the simulated checkout page +4. Click "Pay Now" to simulate successful payment or "Cancel" to simulate cancellation +5. The webhook will be triggered automatically, updating the registration status + +#### Option B: Manual API Control + +1. Create a registration to generate a payment +2. Use the Local Payment API endpoints to manage the payment: + +```bash +# List all pending payments +GET http://localhost:8080/api/local-payments + +# Get specific payment details +GET http://localhost:8080/api/local-payments/{paymentId} + +# Mark payment as PAID (triggers webhook) +POST http://localhost:8080/api/local-payments/{paymentId}/pay + +# Mark payment as CANCELLED (triggers webhook) +POST http://localhost:8080/api/local-payments/{paymentId}/cancel + +# Mark payment as REFUNDED (triggers webhook) +POST http://localhost:8080/api/local-payments/{paymentId}/refund + +# Clear all payments +POST http://localhost:8080/api/local-payments/clear +``` + +## API Endpoints + +All endpoints are available at `/api/local-payments` and are documented in Swagger UI at `http://localhost:8080/`. + +### List All Payments + +``` +GET /api/local-payments +``` + +Returns all payments currently in memory with their status. + +**Response:** +```json +[ + { + "paymentId": "tr_local_abc123def456", + "description": "Event Registration: Summer Camp 2024", + "customerName": "John Doe", + "customerEmail": "john@example.com", + "amount": 25.0, + "status": "ONGOING", + "domain": "events", + "createdAt": "2024-11-04T10:30:00", + "checkoutUrl": "http://localhost:8080/api/local-payments/checkout?paymentId=tr_local_abc123def456", + "redirectUrl": "http://localhost:8080/api/events/confirmation.html?id=1&order_id=42" + } +] +``` + +### Get Payment Details + +``` +GET /api/local-payments/{paymentId} +``` + +Returns detailed information about a specific payment. + +### Process Payment Actions + +``` +POST /api/local-payments/{paymentId}/pay +POST /api/local-payments/{paymentId}/cancel +POST /api/local-payments/{paymentId}/refund +``` + +Each action: +1. Updates the payment status +2. Triggers the appropriate webhook (`/api/{domain}/updatePayment`) +3. Returns the new status and redirect URL + +**Response:** +```json +{ + "success": true, + "message": "Payment marked as PAID and webhook triggered", + "paymentId": "tr_local_abc123def456", + "newStatus": "PAID", + "redirectUrl": "http://localhost:8080/api/events/confirmation.html?id=1&order_id=42" +} +``` + +### Checkout Page + +``` +GET /api/local-payments/checkout?paymentId={paymentId} +``` + +Returns an HTML page simulating the Mollie checkout experience with: +- Payment details (description, customer, amount) +- "Pay Now" button (marks as PAID and triggers webhook) +- "Cancel Payment" button (marks as CANCELLED and triggers webhook) +- Automatic redirect to the original confirmation page after action + +## Example Workflow + +### Testing Event Registration + +1. **Create an event registration:** + ```bash + curl -X POST http://localhost:8080/api/events/1/register \ + -H "Content-Type: application/json" \ + -d '{ + "firstName": "John", + "name": "Doe", + "email": "john@example.com", + "mobile": "+32475123456", + "additionalData": {} + }' + ``` + + **Response:** + ```json + { + "url": "http://localhost:8080/api/local-payments/checkout?paymentId=tr_local_abc123def456" + } + ``` + +2. **Check the payment was created:** + ```bash + curl http://localhost:8080/api/local-payments + ``` + +3. **Option 1 - Use the checkout page:** + - Open `http://localhost:8080/api/local-payments/checkout?paymentId=tr_local_abc123def456` in browser + - Click "Pay Now" + - Observe webhook being triggered in logs + - Redirected to confirmation page + +4. **Option 2 - Manually mark as paid:** + ```bash + curl -X POST http://localhost:8080/api/local-payments/tr_local_abc123def456/pay + ``` + +5. **Verify the registration is marked as paid:** + The webhook automatically updates the registration status and sends confirmation email. + +### Testing Refunds + +1. **First, create and pay for a registration** (see above) + +2. **Cancel the registration:** + ```bash + curl -X POST http://localhost:8080/api/events/registrations/1/cancel + ``` + +3. **The stub automatically:** + - Marks the payment as REFUNDED + - Triggers the webhook + - Sends refund confirmation email + +## Testing Different Scenarios + +### Pattern-Based Status (For Tests) + +The stub supports pattern-based payment IDs for integration tests: + +```kotlin +// In test code, you can create payments with specific IDs: +payment.paymentId = "PAID_test_payment_1" // Returns PAID status +payment.paymentId = "CANCELLED_test_payment_2" // Returns CANCELLED status +payment.paymentId = "REFUNDED_test_payment_3" // Returns REFUNDED status +payment.paymentId = "ONGOING_test_payment_4" // Returns ONGOING status +``` + +This is useful for testing webhook handlers without needing to make API calls. + +## Logging + +The stub logs all payment operations for debugging: + +``` +💳 Created local payment: tr_local_abc123def456 for Event Registration: Summer Camp + Customer: John Doe (john@example.com) + Amount: €25.0 + Checkout: http://localhost:8080/api/local-payments/checkout?paymentId=tr_local_abc123def456 + Domain: events + +✅ Updated payment tr_local_abc123def456 status to PAID +``` + +## Differences from Real Mollie + +While the stub simulates the Mollie flow, there are some differences: + +| Feature | Real Mollie | Local Stub | +|---------|-------------|------------| +| API Key Required | Yes | No | +| External Checkout Page | Yes | Local HTML page | +| Webhook Delay | Varies | Immediate | +| Payment IDs | `tr_xxx` format | `tr_local_xxx` format | +| Customer IDs | `cst_xxx` format | `cst_local_xxx` format | +| Transaction Fees | Real fees applied | Simulated (€1 deduction on refund) | +| Payment Methods | Multiple supported | Simulated BANCONTACT | +| Persistent Storage | Mollie servers | In-memory (cleared on restart) | + +## Architecture + +### Components + +1. **LocalCheckoutProvider** (`service/payment/LocalCheckoutProvider.kt`) + - Implements `CheckoutProvider` interface + - Stores payments in `ConcurrentHashMap` + - Only active when Mollie Client bean is missing + - Uses `@ConditionalOnMissingBean(Client::class)` + +2. **LocalPaymentController** (`controller/LocalPaymentController.kt`) + - REST API for managing test payments + - Provides checkout page HTML + - Triggers webhooks on status changes + - Only active when `LocalCheckoutProvider` is available + - Uses `@ConditionalOnBean(LocalCheckoutProvider::class)` + +### Bean Conditions + +The application uses Spring's conditional beans to automatically select the right provider: + +```kotlin +// Production - Only created when API key is set +@Bean +@WhenNotBlank("organization.api-key.mollie") +fun mollieApiClient(@Value("\${organization.api-key.mollie}") apiKey: String): Client + +// Production - Only created when Client bean exists +@Service +@ConditionalOnBean(Client::class) +class MollieCheckout : CheckoutProvider + +// Development - Only created when Client bean is missing +@Service +@ConditionalOnMissingBean(Client::class) +class LocalCheckoutProvider : CheckoutProvider +``` + +This ensures only one `CheckoutProvider` implementation is active at a time. + +## Troubleshooting + +### Stub not activating + +**Symptom:** Application tries to use real Mollie API in local mode + +**Solution:** Check that `organization.api-key.mollie` is blank or missing in `application-local.yml` + +### Webhooks not triggering + +**Symptom:** Payment status updates but registration doesn't update + +**Solution:** The controller automatically triggers webhooks when you use the `/pay`, `/cancel`, or `/refund` endpoints. If using the checkout page, webhooks are triggered via JavaScript after the action completes. + +### Payments disappear after restart + +**Expected behavior:** Payments are stored in memory and cleared on application restart. This is intentional for development. Use the `/api/local-payments` endpoint to view active payments. + +### Cannot see Local Payment endpoints in Swagger + +**Check:** The `LocalPaymentController` is only registered when `LocalCheckoutProvider` bean exists. Verify the stub is active by checking logs for the initialization message. + +## Testing with Postman/Insomnia + +Import this collection to test the payment flow: + +```json +{ + "name": "Local Payment Testing", + "requests": [ + { + "name": "List Payments", + "method": "GET", + "url": "http://localhost:8080/api/local-payments" + }, + { + "name": "Mark as Paid", + "method": "POST", + "url": "http://localhost:8080/api/local-payments/{{paymentId}}/pay" + }, + { + "name": "Mark as Cancelled", + "method": "POST", + "url": "http://localhost:8080/api/local-payments/{{paymentId}}/cancel" + }, + { + "name": "Clear Payments", + "method": "POST", + "url": "http://localhost:8080/api/local-payments/clear" + } + ] +} +``` + +## Integration Tests + +The stub is designed to work seamlessly with integration tests: + +```kotlin +@SpringBootTest +@AutoConfigureMockMvc +class PaymentFlowIntegrationTest { + + @Autowired + private lateinit var localCheckoutProvider: LocalCheckoutProvider + + @Test + fun `should process payment successfully`() { + // Create registration + val response = mockMvc.perform(post("/api/events/1/register") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"firstName":"John","name":"Doe","email":"john@example.com"}""")) + .andExpect(status().isOk) + .andReturn() + + // Extract payment ID + val url = jacksonObjectMapper().readTree(response.response.contentAsString) + .get("url").asText() + val paymentId = url.substringAfter("paymentId=") + + // Mark as paid + localCheckoutProvider.updatePaymentStatus(paymentId, SimplifiedPaymentStatus.PAID) + + // Trigger webhook + mockMvc.perform(post("/api/events/updatePayment") + .param("id", paymentId)) + .andExpect(status().isOk) + + // Verify registration is paid + // ... assertions + } +} +``` + +## Migration to Production + +When deploying to production, simply set the Mollie API key: + +```bash +export ORGANIZATION_API_KEY_MOLLIE="live_xxxxxxxxxxxxxxxxxx" +``` + +The application will automatically: +1. Create the Mollie `Client` bean +2. Activate `MollieCheckout` instead of `LocalCheckoutProvider` +3. Disable `LocalPaymentController` +4. Use real Mollie API for all payments + +No code changes required! + +## Summary + +The local payment stub provides a complete, production-like payment experience for development: + +✅ No external dependencies (no Mollie account needed) +✅ Full control over payment status +✅ Automatic webhook triggering +✅ Visual checkout page +✅ Complete API for testing +✅ Seamless transition to production +✅ Integration test friendly + +Happy testing! 🚀 diff --git a/src/main/kotlin/be/sgl/backend/controller/LocalPaymentController.kt b/src/main/kotlin/be/sgl/backend/controller/LocalPaymentController.kt new file mode 100644 index 0000000..70af611 --- /dev/null +++ b/src/main/kotlin/be/sgl/backend/controller/LocalPaymentController.kt @@ -0,0 +1,405 @@ +package be.sgl.backend.controller + +import be.sgl.backend.entity.SimplifiedPaymentStatus +import be.sgl.backend.service.activity.ActivityRegistrationService +import be.sgl.backend.service.event.EventRegistrationService +import be.sgl.backend.service.membership.MembershipService +import be.sgl.backend.service.payment.LocalCheckoutProvider +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +private val logger = KotlinLogging.logger {} + +/** + * Controller for managing local test payments during development. + * + * Only available when LocalCheckoutProvider is active (i.e., when Mollie is not configured). + * + * Endpoints: + * - GET /api/local-payments - List all payments + * - GET /api/local-payments/{paymentId} - Get payment details + * - POST /api/local-payments/{paymentId}/pay - Mark payment as PAID and trigger webhook + * - POST /api/local-payments/{paymentId}/cancel - Mark payment as CANCELLED and trigger webhook + * - POST /api/local-payments/{paymentId}/refund - Mark payment as REFUNDED and trigger webhook + * - GET /api/local-payments/checkout - Simple checkout page simulation + * - POST /api/local-payments/clear - Clear all payments + */ +@RestController +@RequestMapping("/api/local-payments") +@ConditionalOnBean(LocalCheckoutProvider::class) +@Tag(name = "Local Payments (Dev Only)", description = "Development-only endpoints for testing payments locally") +class LocalPaymentController { + + @Autowired + private lateinit var localCheckoutProvider: LocalCheckoutProvider + + @Autowired + private lateinit var eventRegistrationService: EventRegistrationService + + @Autowired + private lateinit var activityRegistrationService: ActivityRegistrationService + + @Autowired + private lateinit var membershipService: MembershipService + + @GetMapping + @Operation(summary = "List all local payments", description = "Returns all payments stored in memory") + fun listPayments(): ResponseEntity> { + val payments = localCheckoutProvider.getAllPayments().map { data -> + PaymentListItem( + paymentId = data.paymentId, + description = data.payment.getDescription(), + customerName = data.customer.name, + customerEmail = data.customer.email, + amount = data.payment.price, + status = data.status, + domain = data.domain, + createdAt = data.createdAt.toString(), + checkoutUrl = data.checkoutUrl, + redirectUrl = data.redirectUrl + ) + } + return ResponseEntity.ok(payments) + } + + @GetMapping("/{paymentId}") + @Operation(summary = "Get payment details", description = "Returns detailed information about a specific payment") + fun getPayment(@PathVariable paymentId: String): ResponseEntity { + val data = localCheckoutProvider.getPaymentData(paymentId) + ?: return ResponseEntity.notFound().build() + + val detail = PaymentDetail( + paymentId = data.paymentId, + description = data.payment.getDescription(), + customer = CustomerInfo( + id = data.customer.id, + name = data.customer.name, + email = data.customer.email + ), + amount = data.payment.price, + status = data.status, + domain = data.domain, + payableId = data.payableId, + orderId = data.payment.id, + paid = data.payment.paid, + checkoutUrl = data.checkoutUrl, + redirectUrl = data.redirectUrl, + webhookUrl = getWebhookUrl(data.domain), + createdAt = data.createdAt.toString(), + updatedAt = data.updatedAt.toString() + ) + return ResponseEntity.ok(detail) + } + + @PostMapping("/{paymentId}/pay") + @Operation( + summary = "Mark payment as PAID", + description = "Updates payment status to PAID and triggers the webhook" + ) + fun markAsPaid(@PathVariable paymentId: String): ResponseEntity { + val data = localCheckoutProvider.getPaymentData(paymentId) + ?: return ResponseEntity.notFound().build() + + localCheckoutProvider.updatePaymentStatus(paymentId, SimplifiedPaymentStatus.PAID) + triggerWebhook(data.domain, paymentId) + + logger.info { "✅ Payment $paymentId marked as PAID and webhook triggered" } + return ResponseEntity.ok( + PaymentActionResult( + success = true, + message = "Payment marked as PAID and webhook triggered", + paymentId = paymentId, + newStatus = SimplifiedPaymentStatus.PAID, + redirectUrl = data.redirectUrl + ) + ) + } + + @PostMapping("/{paymentId}/cancel") + @Operation( + summary = "Mark payment as CANCELLED", + description = "Updates payment status to CANCELLED and triggers the webhook" + ) + fun markAsCancelled(@PathVariable paymentId: String): ResponseEntity { + val data = localCheckoutProvider.getPaymentData(paymentId) + ?: return ResponseEntity.notFound().build() + + localCheckoutProvider.updatePaymentStatus(paymentId, SimplifiedPaymentStatus.CANCELLED) + triggerWebhook(data.domain, paymentId) + + logger.info { "❌ Payment $paymentId marked as CANCELLED and webhook triggered" } + return ResponseEntity.ok( + PaymentActionResult( + success = true, + message = "Payment marked as CANCELLED and webhook triggered", + paymentId = paymentId, + newStatus = SimplifiedPaymentStatus.CANCELLED, + redirectUrl = data.redirectUrl + ) + ) + } + + @PostMapping("/{paymentId}/refund") + @Operation( + summary = "Mark payment as REFUNDED", + description = "Updates payment status to REFUNDED and triggers the webhook" + ) + fun markAsRefunded(@PathVariable paymentId: String): ResponseEntity { + val data = localCheckoutProvider.getPaymentData(paymentId) + ?: return ResponseEntity.notFound().build() + + localCheckoutProvider.updatePaymentStatus(paymentId, SimplifiedPaymentStatus.REFUNDED) + triggerWebhook(data.domain, paymentId) + + logger.info { "💰 Payment $paymentId marked as REFUNDED and webhook triggered" } + return ResponseEntity.ok( + PaymentActionResult( + success = true, + message = "Payment marked as REFUNDED and webhook triggered", + paymentId = paymentId, + newStatus = SimplifiedPaymentStatus.REFUNDED, + redirectUrl = data.redirectUrl + ) + ) + } + + @GetMapping("/checkout", produces = [MediaType.TEXT_HTML_VALUE]) + @Operation( + summary = "Checkout page simulation", + description = "Simple HTML page to simulate Mollie checkout flow" + ) + fun checkoutPage(@RequestParam paymentId: String): ResponseEntity { + val data = localCheckoutProvider.getPaymentData(paymentId) + ?: return ResponseEntity.ok(""" + + + + Payment Not Found + + + +

Payment Not Found

+

Payment ID: $paymentId

+ + + """.trimIndent()) + + val html = """ + + + + Local Payment Checkout - ${data.payment.getDescription()} + + + + +
+

🏕️ Local Payment Checkout

+ +
+
+ Description: + ${data.payment.getDescription()} +
+
+ Customer: + ${data.customer.name} +
+
+ Email: + ${data.customer.email} +
+
+ Amount: + €${String.format("%.2f", data.payment.price)} +
+
+ Payment ID: + ${data.paymentId} +
+
+ +
+ ⚠️ Development Mode: This is a simulated payment page for local testing. + Click "Pay Now" to simulate a successful payment, or "Cancel" to simulate a cancelled payment. +
+ +
+ + +
+ +
+
+ + + + + """.trimIndent() + + return ResponseEntity.ok(html) + } + + @PostMapping("/clear") + @Operation( + summary = "Clear all payments", + description = "Removes all payments from memory (for testing cleanup)" + ) + fun clearPayments(): ResponseEntity> { + localCheckoutProvider.clearAllPayments() + return ResponseEntity.ok(mapOf("message" to "All payments cleared")) + } + + private fun triggerWebhook(domain: String, paymentId: String) { + when (domain) { + "events" -> eventRegistrationService.updatePayment(paymentId) + "activities" -> activityRegistrationService.updatePayment(paymentId) + "memberships" -> membershipService.updatePayment(paymentId) + else -> logger.warn { "Unknown domain: $domain" } + } + } + + private fun getWebhookUrl(domain: String): String { + return "/api/$domain/updatePayment" + } + + // DTOs + data class PaymentListItem( + val paymentId: String, + val description: String, + val customerName: String, + val customerEmail: String, + val amount: Double, + val status: SimplifiedPaymentStatus, + val domain: String, + val createdAt: String, + val checkoutUrl: String, + val redirectUrl: String + ) + + data class PaymentDetail( + val paymentId: String, + val description: String, + val customer: CustomerInfo, + val amount: Double, + val status: SimplifiedPaymentStatus, + val domain: String, + val payableId: Int?, + val orderId: Int?, + val paid: Boolean, + val checkoutUrl: String, + val redirectUrl: String, + val webhookUrl: String, + val createdAt: String, + val updatedAt: String + ) + + data class CustomerInfo( + val id: String?, + val name: String, + val email: String + ) + + data class PaymentActionResult( + val success: Boolean, + val message: String, + val paymentId: String, + val newStatus: SimplifiedPaymentStatus, + val redirectUrl: String + ) +} diff --git a/src/main/kotlin/be/sgl/backend/service/payment/LocalCheckoutProvider.kt b/src/main/kotlin/be/sgl/backend/service/payment/LocalCheckoutProvider.kt new file mode 100644 index 0000000..e3f9616 --- /dev/null +++ b/src/main/kotlin/be/sgl/backend/service/payment/LocalCheckoutProvider.kt @@ -0,0 +1,202 @@ +package be.sgl.backend.service.payment + +import be.sgl.backend.dto.Customer +import be.sgl.backend.entity.Payment +import be.sgl.backend.entity.SimplifiedPaymentStatus +import be.sgl.backend.util.appendRequestParameters +import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.stereotype.Service +import be.woutschoovaerts.mollie.Client +import java.time.LocalDateTime +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +private val logger = KotlinLogging.logger {} + +/** + * Local/Development implementation of CheckoutProvider for testing payments without Mollie API. + * + * This provider simulates the Mollie payment flow locally: + * - Generates unique payment IDs + * - Stores payment data in memory + * - Supports all payment statuses (PAID, CANCELLED, REFUNDED, ONGOING) + * - Provides deterministic behavior for testing + * + * Only activated when Mollie Client bean is not available (i.e., when organization.api-key.mollie is blank). + * + * Payment Status Control: + * - Use paymentId patterns to control status: "PAID_xxx", "CANCELLED_xxx", "REFUNDED_xxx" + * - Or use the LocalPaymentController to manually update payment statuses + */ +@Service +@ConditionalOnMissingBean(Client::class) +class LocalCheckoutProvider : CheckoutProvider { + + @Value("\${spring.application.base-url:http://localhost:8080}") + private lateinit var baseUrl: String + + @Value("\${spring.application.public-base-url:http://localhost:8080}") + private lateinit var publicBaseUrl: String + + // In-memory storage for testing + private val payments = ConcurrentHashMap() + private val customers = ConcurrentHashMap() + + data class PaymentData( + val paymentId: String, + val customer: Customer, + val payment: Payment, + val domain: String, + val payableId: Int?, + val checkoutUrl: String, + val redirectUrl: String, + var status: SimplifiedPaymentStatus = SimplifiedPaymentStatus.ONGOING, + val createdAt: LocalDateTime = LocalDateTime.now(), + var updatedAt: LocalDateTime = LocalDateTime.now() + ) + + init { + logger.info { "🎭 LocalCheckoutProvider initialized - Using LOCAL payment stub for development" } + logger.info { "💡 Tip: Use LocalPaymentController endpoints to manage test payments" } + } + + override fun createRedirectUrl(payment: Payment, domain: String, payableId: Int?): String { + val redirectUrl = appendRequestParameters( + "$baseUrl/$domain/confirmation.html", + "id" to payableId, + "order_id" to payment.id + ) + logger.debug { "Created redirect URL: $redirectUrl" } + return redirectUrl + } + + override fun createCheckoutUrl( + customer: Customer, + payment: Payment, + domain: String, + payableId: Int? + ): String { + checkNotNull(payment.id) { "Payment ID must not be null" } + + // Generate unique payment ID + val paymentId = generatePaymentId() + payment.paymentId = paymentId + + // Create customer ID if not exists + val customerId = customer.id ?: createCustomerId(customer) + val customerWithId = customer.copy(id = customerId) + customers[customerId] = customerWithId + + // Create checkout URL + val checkoutUrl = "$publicBaseUrl/api/local-payments/checkout?paymentId=$paymentId" + val redirectUrl = createRedirectUrl(payment, domain, payableId) + + // Store payment data + val paymentData = PaymentData( + paymentId = paymentId, + customer = customerWithId, + payment = payment, + domain = domain, + payableId = payableId, + checkoutUrl = checkoutUrl, + redirectUrl = redirectUrl, + status = SimplifiedPaymentStatus.ONGOING + ) + payments[paymentId] = paymentData + + logger.info { "💳 Created local payment: $paymentId for ${payment.getDescription()}" } + logger.info { " Customer: ${customer.name} (${customer.email})" } + logger.info { " Amount: €${payment.price}" } + logger.info { " Checkout: $checkoutUrl" } + logger.info { " Domain: $domain" } + + return checkoutUrl + } + + override fun getCheckoutUrl(payment: Payment): String { + checkNotNull(payment.paymentId) { "Payment ID must not be null" } + val paymentData = payments[payment.paymentId] + ?: throw IllegalStateException("Payment ${payment.paymentId} not found in local storage") + return paymentData.checkoutUrl + } + + override fun getPaymentStatusById(paymentId: String): SimplifiedPaymentStatus { + // Check if payment exists in memory + val paymentData = payments[paymentId] + + if (paymentData != null) { + logger.debug { "Retrieved payment status for $paymentId: ${paymentData.status}" } + return paymentData.status + } + + // Fallback: Support pattern-based status for testing + // This allows tests to control status via paymentId naming + val status = when { + paymentId.startsWith("PAID_", ignoreCase = true) -> SimplifiedPaymentStatus.PAID + paymentId.startsWith("CANCELLED_", ignoreCase = true) -> SimplifiedPaymentStatus.CANCELLED + paymentId.startsWith("REFUNDED_", ignoreCase = true) -> SimplifiedPaymentStatus.REFUNDED + paymentId.startsWith("ONGOING_", ignoreCase = true) -> SimplifiedPaymentStatus.ONGOING + else -> { + logger.warn { "Payment $paymentId not found in local storage, defaulting to ONGOING" } + SimplifiedPaymentStatus.ONGOING + } + } + + logger.debug { "Retrieved payment status for $paymentId (pattern-based): $status" } + return status + } + + override fun refundPayment(payment: Payment) { + check(payment.paid) { "Payment must be paid before refunding" } + checkNotNull(payment.paymentId) { "Payment ID must not be null" } + + val paymentData = payments[payment.paymentId] + if (paymentData != null) { + paymentData.status = SimplifiedPaymentStatus.REFUNDED + paymentData.updatedAt = LocalDateTime.now() + logger.info { "💰 Refunded payment: ${payment.paymentId} (€${payment.price - 1})" } + } else { + logger.warn { "⚠️ Payment ${payment.paymentId} not found in local storage, but refund acknowledged" } + } + } + + /** + * Update payment status (for use by LocalPaymentController) + */ + fun updatePaymentStatus(paymentId: String, status: SimplifiedPaymentStatus): Boolean { + val paymentData = payments[paymentId] ?: return false + paymentData.status = status + paymentData.updatedAt = LocalDateTime.now() + logger.info { "✅ Updated payment $paymentId status to $status" } + return true + } + + /** + * Get all payments (for debugging/testing) + */ + fun getAllPayments(): List = payments.values.toList() + + /** + * Get payment data by ID + */ + fun getPaymentData(paymentId: String): PaymentData? = payments[paymentId] + + /** + * Clear all payments (for testing) + */ + fun clearAllPayments() { + payments.clear() + customers.clear() + logger.info { "🗑️ Cleared all local payments" } + } + + private fun generatePaymentId(): String { + return "tr_local_${UUID.randomUUID().toString().replace("-", "").substring(0, 16)}" + } + + private fun createCustomerId(customer: Customer): String { + return "cst_local_${UUID.randomUUID().toString().replace("-", "").substring(0, 16)}" + } +} diff --git a/src/main/resources_filtered/application-local.yml b/src/main/resources_filtered/application-local.yml index f29295f..739fc83 100644 --- a/src/main/resources_filtered/application-local.yml +++ b/src/main/resources_filtered/application-local.yml @@ -1,6 +1,8 @@ spring: application: environment: local + base-url: http://localhost:8080/api + public-base-url: http://localhost:8080/api datasource: url: jdbc:mariadb://localhost:3308/application username: user @@ -34,5 +36,8 @@ logging: organization: api-key: - mollie: "invalid-key" + # Leave mollie blank to enable LocalCheckoutProvider for local testing + # To use real Mollie in local dev, uncomment and set your test API key: + # mollie: "test_xxxxxxxxxxxxxxxxxx" + mollie: