kybernus 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/application/usecase/PaymentUseCase.java.hbs +89 -0
- package/templates/java-spring/clean/src/main/java/{{packagePath}}/infrastructure/web/payment/PaymentController.java.hbs +78 -0
- package/templates/java-spring/clean/src/main/resources/application.properties.hbs +7 -0
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/inbound/web/PaymentController.java.hbs +78 -0
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/adapters/outbound/stripe/StripeAdapter.java.hbs +76 -0
- package/templates/java-spring/hexagonal/src/main/java/{{packagePath}}/core/service/PaymentService.java.hbs +90 -0
- package/templates/java-spring/hexagonal/src/main/resources/application.properties.hbs +7 -0
- package/templates/java-spring/mvc/src/main/java/{{packagePath}}/controller/PaymentsController.java.hbs +42 -53
- package/templates/java-spring/mvc/src/main/java/{{packagePath}}/service/StripeService.java.hbs +105 -23
- package/templates/nestjs/clean/src/app.module.ts.hbs +3 -1
- package/templates/nestjs/clean/src/application/payment.service.ts.hbs +90 -0
- package/templates/nestjs/clean/src/infrastructure/http/payment.controller.ts.hbs +46 -0
- package/templates/nestjs/clean/src/infrastructure/stripe.provider.ts.hbs +51 -0
- package/templates/nestjs/clean/src/main.ts.hbs +13 -4
- package/templates/nestjs/clean/src/payment.module.ts.hbs +23 -0
- package/templates/nestjs/hexagonal/src/adapters/inbound/payment.controller.ts.hbs +46 -0
- package/templates/nestjs/hexagonal/src/adapters/outbound/stripe.adapter.ts.hbs +54 -0
- package/templates/nestjs/hexagonal/src/app.module.ts.hbs +2 -0
- package/templates/nestjs/hexagonal/src/core/payment.service.ts.hbs +90 -0
- package/templates/nestjs/hexagonal/src/main.ts.hbs +13 -4
- package/templates/nestjs/hexagonal/src/payment.module.ts.hbs +23 -0
- package/templates/nestjs/mvc/src/main.ts.hbs +6 -3
- package/templates/nestjs/mvc/src/payments/payments.controller.ts.hbs +33 -8
- package/templates/nestjs/mvc/src/payments/payments.service.ts.hbs +66 -22
- package/templates/nextjs/mvc/src/app/api/checkout/route.ts.hbs +42 -13
- package/templates/nextjs/mvc/src/app/api/portal/route.ts.hbs +36 -0
- package/templates/nextjs/mvc/src/app/api/webhook/route.ts.hbs +32 -20
- package/templates/nodejs-express/clean/src/application/services/PaymentService.ts.hbs +98 -0
- package/templates/nodejs-express/clean/src/index.ts.hbs +29 -8
- package/templates/nodejs-express/clean/src/infrastructure/http/controllers/PaymentController.ts.hbs +57 -0
- package/templates/nodejs-express/clean/src/infrastructure/providers/StripeProvider.ts.hbs +45 -0
- package/templates/nodejs-express/hexagonal/src/adapters/inbound/http/PaymentController.ts.hbs +57 -0
- package/templates/nodejs-express/hexagonal/src/adapters/outbound/StripeAdapter.ts.hbs +48 -0
- package/templates/nodejs-express/hexagonal/src/core/PaymentService.ts.hbs +89 -0
- package/templates/nodejs-express/hexagonal/src/index.ts.hbs +28 -8
- package/templates/nodejs-express/mvc/src/app.ts.hbs +11 -2
- package/templates/nodejs-express/mvc/src/controllers/payments.controller.ts.hbs +31 -47
- package/templates/nodejs-express/mvc/src/services/stripe.service.ts.hbs +66 -49
- package/templates/python-fastapi/clean/app/application/services/payment_service.py.hbs +85 -0
- package/templates/python-fastapi/clean/app/infrastructure/http/payment_controller.py.hbs +64 -0
- package/templates/python-fastapi/clean/app/infrastructure/stripe_provider.py.hbs +44 -0
- package/templates/python-fastapi/clean/app/main.py.hbs +8 -5
- package/templates/python-fastapi/hexagonal/app/adapters/inbound/payment_http_adapter.py.hbs +64 -0
- package/templates/python-fastapi/hexagonal/app/adapters/outbound/stripe_adapter.py.hbs +44 -0
- package/templates/python-fastapi/hexagonal/app/core/payment_service.py.hbs +81 -0
- package/templates/python-fastapi/hexagonal/app/main.py.hbs +9 -3
- package/templates/python-fastapi/mvc/app/controllers/payments.py.hbs +70 -35
- package/templates/python-fastapi/mvc/app/services/stripe_service.py.hbs +58 -0
package/package.json
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
package {{packageName}}.application.usecase;
|
|
2
|
+
|
|
3
|
+
import {{packageName}}.domain.entity.User;
|
|
4
|
+
import {{packageName}}.domain.repository.UserRepository;
|
|
5
|
+
import {{packageName}}.infrastructure.stripe.StripeGateway;
|
|
6
|
+
import com.stripe.model.Event;
|
|
7
|
+
import com.stripe.model.checkout.Session;
|
|
8
|
+
import org.slf4j.Logger;
|
|
9
|
+
import org.slf4j.LoggerFactory;
|
|
10
|
+
import org.springframework.stereotype.Service;
|
|
11
|
+
|
|
12
|
+
@Service
|
|
13
|
+
public class PaymentUseCase {
|
|
14
|
+
|
|
15
|
+
private static final Logger logger = LoggerFactory.getLogger(PaymentUseCase.class);
|
|
16
|
+
|
|
17
|
+
private final UserRepository userRepository;
|
|
18
|
+
private final StripeGateway stripeGateway;
|
|
19
|
+
|
|
20
|
+
public PaymentUseCase(UserRepository userRepository, StripeGateway stripeGateway) {
|
|
21
|
+
this.userRepository = userRepository;
|
|
22
|
+
this.stripeGateway = stripeGateway;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public String createCheckoutSession(String userId, String pricpackage {{packageName}}.application.usecase;
|
|
26
|
+
|
|
27
|
+
import {{packageName}}.domain.l.UUID.fromString(userId))
|
|
28
|
+
.orElseThrow(() -> new RuntimeException("User not found"));
|
|
29
|
+
|
|
30
|
+
String customerId = user.getStripeCustomerId();
|
|
31
|
+
|
|
32
|
+
if (customerId == null || customerId.isEmpty()) {
|
|
33
|
+
cuimport com.stripeGateway.createCustomer(user.getEmail(), userId).getId();
|
|
34
|
+
user.setStripeCustomerId(customerId);
|
|
35
|
+
userRepository.save(user);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Session session = stripeGateway.createCheckoutSession(customerId, priceId, userId);
|
|
39
|
+
retu
|
|
40
|
+
private final UserRepository userRString createPortalSession(String userId) throws Exception {
|
|
41
|
+
User user = userRepository.findById(java.util.UUID.fromString(userId))
|
|
42
|
+
.orElseThrow(() -> new RuntimeException("User not found"));
|
|
43
|
+
|
|
44
|
+
if (user.getStripeCustomerId() == null) {
|
|
45
|
+
throw new RuntimeException("No Stripe customer found for this user");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return stripeGateway.createPortalSession(user.getStripeCustomerId()).getUrl();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public void handleWebhook(String payload, String sigHeader) throws Exception {
|
|
52
|
+
Event event = stripeGateway.constructWebhookEvent(payload, sigHeader);
|
|
53
|
+
|
|
54
|
+
switch (event.getType()) {
|
|
55
|
+
case "checkout.session.completed": {
|
|
56
|
+
Session session = (Session) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
57
|
+
if (session != null && session.getClientReferenceId() != null) {
|
|
58
|
+
String userId = session.getClientReferenceId();
|
|
59
|
+
userRepository.findById(java.util.UUID.fromString(userId)).ifPresent(user -> {
|
|
60
|
+
user.setStripeCustomerId(session.getCustomer());
|
|
61
|
+
userRepository.save(user);
|
|
62
|
+
});
|
|
63
|
+
logger.info("Checkout completed for user: {}", userId);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "customer.subscription.updated": {
|
|
68
|
+
com.stripe.model.Subscription sub =
|
|
69
|
+
(com.stripe.model.Subscription) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
70
|
+
if (sub != null) logger.info("Subscription updated: {} | Status: {}", sub.getId(), sub.getStatus());
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case "customer.subscription.deleted": {
|
|
74
|
+
com.stripe.model.Subscription sub =
|
|
75
|
+
(com.stripe.model.Subscription) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
76
|
+
if (sub != null) logger.info("Subscription deleted: {}", sub.getId());
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
case "invoice.payment_failed": {
|
|
80
|
+
com.stripe.model.Invoice invoice =
|
|
81
|
+
(com.stripe.model.Invoice) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
82
|
+
if (invoice != null) logger.info("Payment failed for invoice: {}", invoice.getId());
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
default:
|
|
86
|
+
logger.info("Unhandled Stripe event: {}", event.getType());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package {{packageName}}.infrastructure.web.payment;
|
|
2
|
+
|
|
3
|
+
import {{packageName}}.application.usecase.PaymentUseCase;
|
|
4
|
+
import org.slf4j.Logger;
|
|
5
|
+
import org.slf4j.LoggerFactory;
|
|
6
|
+
import org.springframework.http.HttpStatus;
|
|
7
|
+
import org.springframework.http.ResponseEntity;
|
|
8
|
+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
9
|
+
import org.springframework.security.core.userdetails.UserDetails;
|
|
10
|
+
import org.springframework.web.bind.annotation.*;
|
|
11
|
+
|
|
12
|
+
import java.util.Map;
|
|
13
|
+
|
|
14
|
+
@RestController
|
|
15
|
+
@RequestMapping("/api/payments")
|
|
16
|
+
public class PaymentController {
|
|
17
|
+
|
|
18
|
+
private static final Logger logger = LoggerFactory.getLogger(PaymentController.class);
|
|
19
|
+
|
|
20
|
+
private final PaymentUseCase paymentUseCase;
|
|
21
|
+
|
|
22
|
+
public PaymentController(PaymentUseCase paymentUseCase) {
|
|
23
|
+
this.paymentUseCase = paymentUseCase;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* POST /api/payments/checkout
|
|
28
|
+
* Requires authentication.
|
|
29
|
+
*/
|
|
30
|
+
@PostMapping("/checkout")
|
|
31
|
+
public ResponseEntity<?> createCheckout(
|
|
32
|
+
@AuthenticationPrincipal UserDetails userDetails,
|
|
33
|
+
@RequestBody Map<String, String> body) {
|
|
34
|
+
try {
|
|
35
|
+
String priceId = body.get("priceId");
|
|
36
|
+
if (priceId == null || priceId.isEmpty()) {
|
|
37
|
+
return ResponseEntity.badRequest().body(Map.of("error", "priceId is required"));
|
|
38
|
+
}
|
|
39
|
+
String url = paymentUseCase.createCheckoutSession(userDetails.getUsername(), priceId);
|
|
40
|
+
return ResponseEntity.ok(Map.of("url", url));
|
|
41
|
+
} catch (Exception e) {
|
|
42
|
+
logger.error("Checkout error", e);
|
|
43
|
+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* POST /api/payments/portal
|
|
49
|
+
* Requires authentication.
|
|
50
|
+
*/
|
|
51
|
+
@PostMapping("/portal")
|
|
52
|
+
public ResponseEntity<?> createPortal(@AuthenticationPrincipal UserDetails userDetails) {
|
|
53
|
+
try {
|
|
54
|
+
String url = paymentUseCase.createPortalSession(userDetails.getUsername());
|
|
55
|
+
return ResponseEntity.ok(Map.of("url", url));
|
|
56
|
+
} catch (Exception e) {
|
|
57
|
+
logger.error("Portal error", e);
|
|
58
|
+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* POST /api/payments/webhook
|
|
64
|
+
* No authentication. Stripe sends raw body.
|
|
65
|
+
*/
|
|
66
|
+
@PostMapping("/webhook")
|
|
67
|
+
public ResponseEntity<String> webhook(
|
|
68
|
+
@RequestBody String payload,
|
|
69
|
+
@RequestHeader("Stripe-Signature") String sigHeader) {
|
|
70
|
+
try {
|
|
71
|
+
paymentUseCase.handleWebhook(payload, sigHeader);
|
|
72
|
+
return ResponseEntity.ok("{\"received\": true}");
|
|
73
|
+
} catch (Exception e) {
|
|
74
|
+
logger.warn("Webhook error: {}", e.getMessage());
|
|
75
|
+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -16,3 +16,10 @@ spring.jpa.properties.hibernate.format_sql=true
|
|
|
16
16
|
application.security.jwt.secret-key={{jwtSecretKey}}
|
|
17
17
|
application.security.jwt.expiration=86400000
|
|
18
18
|
application.security.jwt.refresh-token.expiration=604800000
|
|
19
|
+
|
|
20
|
+
# Stripe
|
|
21
|
+
stripe.secret-key=${STRIPE_SECRET_KEY}
|
|
22
|
+
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
|
|
23
|
+
|
|
24
|
+
# Frontend URL (for Stripe redirect URLs)
|
|
25
|
+
frontend.url=${FRONTEND_URL:http://localhost:3000}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package {{packageName}}.adapters.inbound.web;
|
|
2
|
+
|
|
3
|
+
import {{packageName}}.core.service.PaymentService;
|
|
4
|
+
import org.slf4j.Logger;
|
|
5
|
+
import org.slf4j.LoggerFactory;
|
|
6
|
+
import org.springframework.http.HttpStatus;
|
|
7
|
+
import org.springframework.http.ResponseEntity;
|
|
8
|
+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
9
|
+
import org.springframework.security.core.userdetails.UserDetails;
|
|
10
|
+
import org.springframework.web.bind.annotation.*;
|
|
11
|
+
|
|
12
|
+
import java.util.Map;
|
|
13
|
+
|
|
14
|
+
@RestController
|
|
15
|
+
@RequestMapping("/api/payments")
|
|
16
|
+
public class PaymentController {
|
|
17
|
+
|
|
18
|
+
private static final Logger logger = LoggerFactory.getLogger(PaymentController.class);
|
|
19
|
+
|
|
20
|
+
private final PaymentService paymentService;
|
|
21
|
+
|
|
22
|
+
public PaymentController(PaymentService paymentService) {
|
|
23
|
+
this.paymentService = paymentService;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* POST /api/payments/checkout
|
|
28
|
+
* Requires authentication.
|
|
29
|
+
*/
|
|
30
|
+
@PostMapping("/checkout")
|
|
31
|
+
public ResponseEntity<?> createCheckout(
|
|
32
|
+
@AuthenticationPrincipal UserDetails userDetails,
|
|
33
|
+
@RequestBody Map<String, String> body) {
|
|
34
|
+
try {
|
|
35
|
+
String priceId = body.get("priceId");
|
|
36
|
+
if (priceId == null || priceId.isEmpty()) {
|
|
37
|
+
return ResponseEntity.badRequest().body(Map.of("error", "priceId is required"));
|
|
38
|
+
}
|
|
39
|
+
String url = paymentService.createCheckoutSession(userDetails.getUsername(), priceId);
|
|
40
|
+
return ResponseEntity.ok(Map.of("url", url));
|
|
41
|
+
} catch (Exception e) {
|
|
42
|
+
logger.error("Checkout error", e);
|
|
43
|
+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* POST /api/payments/portal
|
|
49
|
+
* Requires authentication.
|
|
50
|
+
*/
|
|
51
|
+
@PostMapping("/portal")
|
|
52
|
+
public ResponseEntity<?> createPortal(@AuthenticationPrincipal UserDetails userDetails) {
|
|
53
|
+
try {
|
|
54
|
+
String url = paymentService.createPortalSession(userDetails.getUsername());
|
|
55
|
+
return ResponseEntity.ok(Map.of("url", url));
|
|
56
|
+
} catch (Exception e) {
|
|
57
|
+
logger.error("Portal error", e);
|
|
58
|
+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* POST /api/payments/webhook
|
|
64
|
+
* No authentication. Stripe sends raw body.
|
|
65
|
+
*/
|
|
66
|
+
@PostMapping("/webhook")
|
|
67
|
+
public ResponseEntity<String> webhook(
|
|
68
|
+
@RequestBody String payload,
|
|
69
|
+
@RequestHeader("Stripe-Signature") String sigHeader) {
|
|
70
|
+
try {
|
|
71
|
+
paymentService.handleWebhook(payload, sigHeader);
|
|
72
|
+
return ResponseEntity.ok("{\"received\": true}");
|
|
73
|
+
} catch (Exception e) {
|
|
74
|
+
logger.warn("Webhook error: {}", e.getMessage());
|
|
75
|
+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package {{packageName}}.adapters.outbound.stripe;
|
|
2
|
+
|
|
3
|
+
import com.stripe.Stripe;
|
|
4
|
+
import com.stripe.exception.StripeException;
|
|
5
|
+
import com.stripe.model.Customer;
|
|
6
|
+
import com.stripe.model.Event;
|
|
7
|
+
import com.stripe.model.checkout.Session;
|
|
8
|
+
import com.stripe.net.Webhook;
|
|
9
|
+
import com.stripe.param.CustomerCreateParams;
|
|
10
|
+
import com.stripe.param.checkout.SessionCreateParams;
|
|
11
|
+
import jakarta.annotation.PostConstruct;
|
|
12
|
+
import org.slf4j.Logger;
|
|
13
|
+
import org.slf4j.LoggerFactory;
|
|
14
|
+
import org.springframework.beans.factory.annotation.Value;
|
|
15
|
+
import org.springframework.stereotype.Component;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Outbound adapter: wraps the Stripe SDK for use by core services.
|
|
19
|
+
*/
|
|
20
|
+
@Component
|
|
21
|
+
public class StripeAdapter {
|
|
22
|
+
|
|
23
|
+
private static final Logger logger = LoggerFactory.getLogger(StripeAdapter.class);
|
|
24
|
+
|
|
25
|
+
@Value("${stripe.secret-key}")
|
|
26
|
+
private String stripeSecretKey;
|
|
27
|
+
|
|
28
|
+
@Value("${stripe.webhook-secret}")
|
|
29
|
+
private String webhookSecret;
|
|
30
|
+
|
|
31
|
+
@Value("${frontend.url}")
|
|
32
|
+
private String frontendUrl;
|
|
33
|
+
|
|
34
|
+
@PostConstruct
|
|
35
|
+
public void init() {
|
|
36
|
+
Stripe.apiKey = stripeSecretKey;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public Customer createCustomer(String email, String userId) throws StripeException {
|
|
40
|
+
return Customer.create(CustomerCreateParams.builder()
|
|
41
|
+
.setEmail(email)
|
|
42
|
+
.putMetadata("userId", userId)
|
|
43
|
+
.build());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public Session createCheckoutSession(
|
|
47
|
+
String customerId, String priceId, String userId) throws StripeException {
|
|
48
|
+
|
|
49
|
+
return Session.create(SessionCreateParams.builder()
|
|
50
|
+
.setMode(SessionCreateParams.Mode.SUBSCRIPTION)
|
|
51
|
+
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.CARD)
|
|
52
|
+
.addLineItem(SessionCreateParams.LineItem.builder()
|
|
53
|
+
.setPrice(priceId)
|
|
54
|
+
.setQuantity(1L)
|
|
55
|
+
.build())
|
|
56
|
+
.setCustomer(customerId)
|
|
57
|
+
.setSuccessUrl(frontendUrl + "/success?session_id={CHECKOUT_SESSION_ID}")
|
|
58
|
+
.setCancelUrl(frontendUrl + "/cancel")
|
|
59
|
+
.setClientReferenceId(userId)
|
|
60
|
+
.build());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
public com.stripe.model.billingportal.Session createPortalSession(
|
|
64
|
+
String customerId) throws StripeException {
|
|
65
|
+
|
|
66
|
+
return com.stripe.model.billingportal.Session.create(
|
|
67
|
+
com.stripe.param.billingportal.SessionCreateParams.builder()
|
|
68
|
+
.setCustomer(customerId)
|
|
69
|
+
.setReturnUrl(frontendUrl + "/dashboard")
|
|
70
|
+
.build());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public Event constructWebhookEvent(String payload, String sigHeader) throws Exception {
|
|
74
|
+
return Webhook.constructEvent(payload, sigHeader, webhookSecret);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
package {{packageName}}.core.service;
|
|
2
|
+
|
|
3
|
+
import {{packageName}}.adapters.outbound.stripe.StripeAdapter;
|
|
4
|
+
import {{packageName}}.core.ports.outbound.UserRepository;
|
|
5
|
+
import {{packageName}}.core.domain.User;
|
|
6
|
+
import com.stripe.model.Event;
|
|
7
|
+
import com.stripe.model.checkout.Session;
|
|
8
|
+
import org.slf4j.Logger;
|
|
9
|
+
import org.slf4j.LoggerFactory;
|
|
10
|
+
import org.springframework.stereotype.Service;
|
|
11
|
+
|
|
12
|
+
@Service
|
|
13
|
+
public class PaymentService {
|
|
14
|
+
|
|
15
|
+
private static final Logger logger = LoggerFactory.getLogger(PaymentService.class);
|
|
16
|
+
|
|
17
|
+
private final UserRepository userRepository;
|
|
18
|
+
private final StripeAdapter stripeAdapter;
|
|
19
|
+
|
|
20
|
+
public PaymentService(UserRepository userRepository, StripeAdapter stripeAdapter) {
|
|
21
|
+
this.userRepository = userRepository;
|
|
22
|
+
this.stripeAdapter = stripeAdapter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public String createCheckoutSession(String userId, String priceId) throws Exception {
|
|
26
|
+
User user = userRepository.findById(java.util.UUID.fromString(userId))
|
|
27
|
+
.orElseThrow(() -> new RuntimeException("User not found"));
|
|
28
|
+
|
|
29
|
+
String customerId = user.getStripeCustomerId();
|
|
30
|
+
|
|
31
|
+
if (customerId == null || customerId.isEmpty()) {
|
|
32
|
+
customerId = stripeAdapter.createCustomer(user.getEmail(), userId).getId();
|
|
33
|
+
user.setStripeCustomerId(customerId);
|
|
34
|
+
userRepository.save(user);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Session session = stripeAdapter.createCheckoutSession(customerId, priceId, userId);
|
|
38
|
+
return session.getUrl();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public String createPortalSession(String userId) throws Exception {
|
|
42
|
+
User user = userRepository.findById(java.util.UUID.fromString(userId))
|
|
43
|
+
.orElseThrow(() -> new RuntimeException("User not found"));
|
|
44
|
+
|
|
45
|
+
if (user.getStripeCustomerId() == null) {
|
|
46
|
+
throw new RuntimeException("No Stripe customer found for this user");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return stripeAdapter.createPortalSession(user.getStripeCustomerId()).getUrl();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public void handleWebhook(String payload, String sigHeader) throws Exception {
|
|
53
|
+
Event event = stripeAdapter.constructWebhookEvent(payload, sigHeader);
|
|
54
|
+
|
|
55
|
+
switch (event.getType()) {
|
|
56
|
+
case "checkout.session.completed": {
|
|
57
|
+
Session session = (Session) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
58
|
+
if (session != null && session.getClientReferenceId() != null) {
|
|
59
|
+
String userId = session.getClientReferenceId();
|
|
60
|
+
userRepository.findById(java.util.UUID.fromString(userId)).ifPresent(user -> {
|
|
61
|
+
user.setStripeCustomerId(session.getCustomer());
|
|
62
|
+
userRepository.save(user);
|
|
63
|
+
});
|
|
64
|
+
logger.info("Checkout completed for user: {}", userId);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "customer.subscription.updated": {
|
|
69
|
+
com.stripe.model.Subscription sub =
|
|
70
|
+
(com.stripe.model.Subscription) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
71
|
+
if (sub != null) logger.info("Subscription updated: {} | Status: {}", sub.getId(), sub.getStatus());
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "customer.subscription.deleted": {
|
|
75
|
+
com.stripe.model.Subscription sub =
|
|
76
|
+
(com.stripe.model.Subscription) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
77
|
+
if (sub != null) logger.info("Subscription deleted: {}", sub.getId());
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case "invoice.payment_failed": {
|
|
81
|
+
com.stripe.model.Invoice invoice =
|
|
82
|
+
(com.stripe.model.Invoice) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
83
|
+
if (invoice != null) logger.info("Payment failed for invoice: {}", invoice.getId());
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
logger.info("Unhandled Stripe event: {}", event.getType());
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -16,3 +16,10 @@ spring.jpa.properties.hibernate.format_sql=true
|
|
|
16
16
|
application.security.jwt.secret-key={{jwtSecretKey}}
|
|
17
17
|
application.security.jwt.expiration=86400000
|
|
18
18
|
application.security.jwt.refresh-token.expiration=604800000
|
|
19
|
+
|
|
20
|
+
# Stripe
|
|
21
|
+
stripe.secret-key=${STRIPE_SECRET_KEY}
|
|
22
|
+
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
|
|
23
|
+
|
|
24
|
+
# Frontend URL (for Stripe redirect URLs)
|
|
25
|
+
frontend.url=${FRONTEND_URL:http://localhost:3000}
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
package {{packageName}}.controller;
|
|
2
2
|
|
|
3
|
-
import com.stripe.exception.SignatureVerificationException;
|
|
4
|
-
import com.stripe.model.Event;
|
|
5
|
-
import com.stripe.net.Webhook;
|
|
6
|
-
import {{packageName}}.model.User;
|
|
7
|
-
import {{packageName}}.repository.UserRepository;
|
|
8
3
|
import {{packageName}}.service.StripeService;
|
|
9
4
|
import org.slf4j.Logger;
|
|
10
5
|
import org.slf4j.LoggerFactory;
|
|
11
|
-
import org.springframework.beans.factory.annotation.Value;
|
|
12
6
|
import org.springframework.http.HttpStatus;
|
|
13
7
|
import org.springframework.http.ResponseEntity;
|
|
8
|
+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|
9
|
+
import org.springframework.security.core.userdetails.UserDetails;
|
|
14
10
|
import org.springframework.web.bind.annotation.*;
|
|
15
11
|
|
|
16
12
|
import java.util.Map;
|
|
17
|
-
import java.util.Optional;
|
|
18
13
|
|
|
19
14
|
@RestController
|
|
20
15
|
@RequestMapping("/api/payments")
|
|
@@ -22,71 +17,65 @@ public class PaymentsController {
|
|
|
22
17
|
|
|
23
18
|
private static final Logger logger = LoggerFactory.getLogger(PaymentsController.class);
|
|
24
19
|
|
|
25
|
-
@Value("${stripe.webhook-secret}")
|
|
26
|
-
private String webhookSecret;
|
|
27
|
-
|
|
28
20
|
private final StripeService stripeService;
|
|
29
|
-
private final UserRepository userRepository;
|
|
30
21
|
|
|
31
|
-
public PaymentsController(StripeService stripeService
|
|
22
|
+
public PaymentsController(StripeService stripeService) {
|
|
32
23
|
this.stripeService = stripeService;
|
|
33
|
-
this.userRepository = userRepository;
|
|
34
24
|
}
|
|
35
25
|
|
|
26
|
+
/**
|
|
27
|
+
* POST /api/payments/checkout
|
|
28
|
+
* Requires authentication. Creates a Stripe Checkout session.
|
|
29
|
+
*/
|
|
36
30
|
@PostMapping("/checkout")
|
|
37
|
-
public ResponseEntity<?> createCheckout(
|
|
31
|
+
public ResponseEntity<?> createCheckout(
|
|
32
|
+
@AuthenticationPrincipal UserDetails userDetails,
|
|
33
|
+
@RequestBody Map<String, String> body) {
|
|
38
34
|
try {
|
|
39
|
-
String
|
|
40
|
-
|
|
35
|
+
String priceId = body.get("priceId");
|
|
36
|
+
if (priceId == null || priceId.isEmpty()) {
|
|
37
|
+
return ResponseEntity.badRequest().body(Map.of("error", "priceId is required"));
|
|
38
|
+
}
|
|
39
|
+
// userDetails.getUsername() returns the user ID (set in JWT filter)
|
|
40
|
+
com.stripe.model.checkout.Session session =
|
|
41
|
+
stripeService.createCheckoutSession(userDetails.getUsername(), priceId);
|
|
41
42
|
return ResponseEntity.ok(Map.of("url", session.getUrl()));
|
|
42
43
|
} catch (Exception e) {
|
|
43
44
|
logger.error("Error creating checkout session", e);
|
|
44
|
-
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
|
45
|
-
|
|
45
|
+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* POST /api/payments/portal
|
|
51
|
+
* Requires authentication. Opens Stripe Billing Portal.
|
|
52
|
+
*/
|
|
53
|
+
@PostMapping("/portal")
|
|
54
|
+
public ResponseEntity<?> createPortal(@AuthenticationPrincipal UserDetails userDetails) {
|
|
55
|
+
try {
|
|
56
|
+
com.stripe.model.billingportal.Session session =
|
|
57
|
+
stripeService.createPortalSession(userDetails.getUsername());
|
|
58
|
+
return ResponseEntity.ok(Map.of("url", session.getUrl()));
|
|
59
|
+
} catch (Exception e) {
|
|
60
|
+
logger.error("Error creating portal session", e);
|
|
61
|
+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", e.getMessage()));
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
|
|
65
|
+
/**
|
|
66
|
+
* POST /api/payments/webhook
|
|
67
|
+
* No authentication. Stripe sends raw body + signature header.
|
|
68
|
+
*/
|
|
49
69
|
@PostMapping("/webhook")
|
|
50
70
|
public ResponseEntity<String> handleWebhook(
|
|
51
71
|
@RequestBody String payload,
|
|
52
72
|
@RequestHeader("Stripe-Signature") String sigHeader) {
|
|
53
|
-
|
|
54
|
-
Event event;
|
|
55
|
-
|
|
56
73
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
logger.warn("Invalid signature", e);
|
|
60
|
-
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid signature");
|
|
74
|
+
stripeService.handleWebhook(payload, sigHeader);
|
|
75
|
+
return ResponseEntity.ok("{\"received\": true}");
|
|
61
76
|
} catch (Exception e) {
|
|
62
|
-
logger.
|
|
63
|
-
|
|
77
|
+
logger.warn("Webhook error: {}", e.getMessage());
|
|
78
|
+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
|
|
64
79
|
}
|
|
65
|
-
|
|
66
|
-
switch (event.getType()) {
|
|
67
|
-
case "checkout.session.completed":
|
|
68
|
-
com.stripe.model.checkout.Session session = (com.stripe.model.checkout.Session) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
69
|
-
if (session != null) {
|
|
70
|
-
logger.info("Checkout completed: {}", session.getId());
|
|
71
|
-
// Match to database user here using session properties
|
|
72
|
-
}
|
|
73
|
-
break;
|
|
74
|
-
case "customer.subscription.updated":
|
|
75
|
-
com.stripe.model.Subscription subscription = (com.stripe.model.Subscription) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
76
|
-
if (subscription != null) {
|
|
77
|
-
logger.info("Subscription updated: {}", subscription.getId());
|
|
78
|
-
}
|
|
79
|
-
break;
|
|
80
|
-
case "customer.subscription.deleted":
|
|
81
|
-
com.stripe.model.Subscription deletedSub = (com.stripe.model.Subscription) event.getDataObjectDeserializer().getObject().orElse(null);
|
|
82
|
-
if (deletedSub != null) {
|
|
83
|
-
logger.info("Subscription deleted: {}", deletedSub.getId());
|
|
84
|
-
}
|
|
85
|
-
break;
|
|
86
|
-
default:
|
|
87
|
-
logger.info("Unhandled event type: {}", event.getType());
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return ResponseEntity.ok("Received");
|
|
91
80
|
}
|
|
92
81
|
}
|