Back to blog

Xây Dựng Video Platform: Tích Hợp Stripe Subscription

javaspring-bootreactnextjsvideo-streaming
Xây Dựng Video Platform: Tích Hợp Stripe Subscription

Platform đã play video, track progress, và stream an toàn. Nhưng nó miễn phí. Đến lúc fix chuyện đó. Trong bài này, chúng ta sẽ tích hợp Stripe để handle subscription payment — user trả tiền hàng tháng hoặc hàng năm, và chỉ subscriber active mới xem được premium content.

Chúng ta dùng Stripe Checkout (hosted payment page) thay vì tự build payment form. Tại sao? Vì handle credit card number nghĩa là phải lo PCI compliance, fraud detection, và đống payment UI edge case mà Stripe đã solve xong rồi. Stripe Checkout cho mình payment experience đã được battle-test mà không cần viết một dòng frontend payment code nào.

Thời gian: 3–4 giờ
Yêu cầu: Phase 8: Video Player & Progress Tracking

Những gì chúng ta sẽ build trong bài này:
✅ Tạo Stripe Checkout session cho plan monthly/yearly
✅ Webhook handler cho subscription lifecycle event
✅ Subscription entity và database schema
✅ Customer Portal cho self-service management
✅ Access gating — chỉ subscriber mới stream được
✅ Pricing page với plan selection
✅ Grace period handling cho payment thất bại


Stripe Checkout Hoạt Động Như Thế Nào

Điểm mấu chốt: đừng bao giờ trust redirect. Khi Stripe redirect user về success page, điều đó chỉ nghĩa là họ đã điền xong form. Subscription confirmation thực sự đến qua webhook — một server-to-server callback từ Stripe. Luôn tạo hoặc update subscription trong webhook handler, không phải trong redirect handler.


Stripe Setup

1. Tạo Product và Price

Trong Stripe Dashboard, tạo một product với hai price:

  • Monthly: $9.99/tháng
  • Yearly: $99.99/năm (tiết kiệm ~17%)

Ghi lại Price ID (ví dụ price_monthly123, price_yearly456) — bạn sẽ cần chúng trong configuration.

2. Thêm Stripe Dependency

<!-- api/pom.xml -->
<dependency>
    <groupId>com.stripe</groupId>
    <artifactId>stripe-java</artifactId>
    <version>26.1.0</version>
</dependency>

3. Configuration

# src/main/resources/application.yml
stripe:
  secret-key: ${STRIPE_SECRET_KEY}
  webhook-secret: ${STRIPE_WEBHOOK_SECRET}
  prices:
    monthly: ${STRIPE_PRICE_MONTHLY:price_monthly123}
    yearly: ${STRIPE_PRICE_YEARLY:price_yearly456}
  success-url: ${APP_URL:http://localhost:3000}/subscription/success?session_id={CHECKOUT_SESSION_ID}
  cancel-url: ${APP_URL:http://localhost:3000}/pricing
// src/main/java/com/videoplatform/api/config/StripeConfig.java
package com.videoplatform.api.config;
 
import com.stripe.Stripe;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
 
@Configuration
@ConfigurationProperties(prefix = "stripe")
public class StripeConfig {
 
    private String secretKey;
    private String webhookSecret;
    private Prices prices = new Prices();
    private String successUrl;
    private String cancelUrl;
 
    @PostConstruct
    public void init() {
        Stripe.apiKey = this.secretKey;
    }
 
    // Getter và setter
    public String getSecretKey() { return secretKey; }
    public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
 
    public String getWebhookSecret() { return webhookSecret; }
    public void setWebhookSecret(String webhookSecret) { this.webhookSecret = webhookSecret; }
 
    public Prices getPrices() { return prices; }
    public void setPrices(Prices prices) { this.prices = prices; }
 
    public String getSuccessUrl() { return successUrl; }
    public void setSuccessUrl(String successUrl) { this.successUrl = successUrl; }
 
    public String getCancelUrl() { return cancelUrl; }
    public void setCancelUrl(String cancelUrl) { this.cancelUrl = cancelUrl; }
 
    public static class Prices {
        private String monthly;
        private String yearly;
 
        public String getMonthly() { return monthly; }
        public void setMonthly(String monthly) { this.monthly = monthly; }
        public String getYearly() { return yearly; }
        public void setYearly(String yearly) { this.yearly = yearly; }
    }
}

Database Schema

Flyway Migration

-- src/main/resources/db/migration/V5__create_subscriptions.sql
 
CREATE TABLE subscriptions (
    id                      BIGSERIAL PRIMARY KEY,
    user_id                 BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    stripe_subscription_id  VARCHAR(255) UNIQUE NOT NULL,
    stripe_customer_id      VARCHAR(255) NOT NULL,
    plan_type               VARCHAR(20) NOT NULL,  -- MONTHLY, YEARLY
    status                  VARCHAR(20) NOT NULL,  -- ACTIVE, CANCELED, PAST_DUE, EXPIRED
    current_period_start    TIMESTAMP NOT NULL,
    current_period_end      TIMESTAMP NOT NULL,
    cancel_at_period_end    BOOLEAN NOT NULL DEFAULT FALSE,
    created_at              TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at              TIMESTAMP NOT NULL DEFAULT NOW()
);
 
CREATE TABLE payment_history (
    id                      BIGSERIAL PRIMARY KEY,
    user_id                 BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    stripe_payment_intent   VARCHAR(255) UNIQUE NOT NULL,
    amount                  INTEGER NOT NULL,  -- tính bằng cent
    currency                VARCHAR(3) NOT NULL DEFAULT 'usd',
    status                  VARCHAR(20) NOT NULL,
    created_at              TIMESTAMP NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);
CREATE INDEX idx_subscriptions_stripe_id ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_customer ON subscriptions(stripe_customer_id);
CREATE INDEX idx_payment_history_user ON payment_history(user_id);

Entity

// src/main/java/com/videoplatform/api/entity/Subscription.java
package com.videoplatform.api.entity;
 
import jakarta.persistence.*;
import java.time.LocalDateTime;
 
@Entity
@Table(name = "subscriptions")
public class Subscription {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
 
    @Column(name = "stripe_subscription_id", unique = true, nullable = false)
    private String stripeSubscriptionId;
 
    @Column(name = "stripe_customer_id", nullable = false)
    private String stripeCustomerId;
 
    @Enumerated(EnumType.STRING)
    @Column(name = "plan_type", nullable = false)
    private PlanType planType;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private SubscriptionStatus status;
 
    @Column(name = "current_period_start", nullable = false)
    private LocalDateTime currentPeriodStart;
 
    @Column(name = "current_period_end", nullable = false)
    private LocalDateTime currentPeriodEnd;
 
    @Column(name = "cancel_at_period_end", nullable = false)
    private boolean cancelAtPeriodEnd = false;
 
    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt = LocalDateTime.now();
 
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt = LocalDateTime.now();
 
    // Getter và setter
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
 
    public User getUser() { return user; }
    public void setUser(User user) { this.user = user; }
 
    public String getStripeSubscriptionId() { return stripeSubscriptionId; }
    public void setStripeSubscriptionId(String stripeSubscriptionId) {
        this.stripeSubscriptionId = stripeSubscriptionId;
    }
 
    public String getStripeCustomerId() { return stripeCustomerId; }
    public void setStripeCustomerId(String stripeCustomerId) {
        this.stripeCustomerId = stripeCustomerId;
    }
 
    public PlanType getPlanType() { return planType; }
    public void setPlanType(PlanType planType) { this.planType = planType; }
 
    public SubscriptionStatus getStatus() { return status; }
    public void setStatus(SubscriptionStatus status) { this.status = status; }
 
    public LocalDateTime getCurrentPeriodStart() { return currentPeriodStart; }
    public void setCurrentPeriodStart(LocalDateTime currentPeriodStart) {
        this.currentPeriodStart = currentPeriodStart;
    }
 
    public LocalDateTime getCurrentPeriodEnd() { return currentPeriodEnd; }
    public void setCurrentPeriodEnd(LocalDateTime currentPeriodEnd) {
        this.currentPeriodEnd = currentPeriodEnd;
    }
 
    public boolean isCancelAtPeriodEnd() { return cancelAtPeriodEnd; }
    public void setCancelAtPeriodEnd(boolean cancelAtPeriodEnd) {
        this.cancelAtPeriodEnd = cancelAtPeriodEnd;
    }
 
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
// src/main/java/com/videoplatform/api/entity/PlanType.java
package com.videoplatform.api.entity;
 
public enum PlanType {
    MONTHLY,
    YEARLY
}
// src/main/java/com/videoplatform/api/entity/SubscriptionStatus.java
package com.videoplatform.api.entity;
 
public enum SubscriptionStatus {
    ACTIVE,
    CANCELED,
    PAST_DUE,
    EXPIRED
}

Tạo Checkout Session

Service

// src/main/java/com/videoplatform/api/service/StripeService.java
package com.videoplatform.api.service;
 
import com.stripe.exception.StripeException;
import com.stripe.model.Customer;
import com.stripe.model.checkout.Session;
import com.stripe.param.CustomerCreateParams;
import com.stripe.param.CustomerListParams;
import com.stripe.param.checkout.SessionCreateParams;
import com.stripe.param.billingportal.SessionCreateParams as PortalSessionCreateParams;
import com.videoplatform.api.config.StripeConfig;
import com.videoplatform.api.entity.PlanType;
import com.videoplatform.api.entity.User;
import org.springframework.stereotype.Service;
 
@Service
public class StripeService {
 
    private final StripeConfig config;
 
    public StripeService(StripeConfig config) {
        this.config = config;
    }
 
    public String createCheckoutSession(User user, PlanType planType) throws StripeException {
        // Lấy hoặc tạo Stripe customer
        String customerId = getOrCreateCustomer(user);
 
        // Chọn price theo plan type
        String priceId = planType == PlanType.MONTHLY
                ? config.getPrices().getMonthly()
                : config.getPrices().getYearly();
 
        SessionCreateParams params = SessionCreateParams.builder()
                .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
                .setCustomer(customerId)
                .setSuccessUrl(config.getSuccessUrl())
                .setCancelUrl(config.getCancelUrl())
                .addLineItem(
                    SessionCreateParams.LineItem.builder()
                        .setPrice(priceId)
                        .setQuantity(1L)
                        .build()
                )
                .setSubscriptionData(
                    SessionCreateParams.SubscriptionData.builder()
                        .putMetadata("user_id", user.getId().toString())
                        .putMetadata("plan_type", planType.name())
                        .build()
                )
                // Cho phép promotion code
                .setAllowPromotionCodes(true)
                .build();
 
        Session session = Session.create(params);
        return session.getUrl();
    }
 
    public String createPortalSession(String stripeCustomerId) throws StripeException {
        var params = com.stripe.param.billingportal.SessionCreateParams.builder()
                .setCustomer(stripeCustomerId)
                .setReturnUrl(config.getCancelUrl()) // Quay về pricing page
                .build();
 
        var session = com.stripe.model.billingportal.Session.create(params);
        return session.getUrl();
    }
 
    private String getOrCreateCustomer(User user) throws StripeException {
        // Kiểm tra customer đã tồn tại chưa
        CustomerListParams listParams = CustomerListParams.builder()
                .setEmail(user.getEmail())
                .setLimit(1L)
                .build();
 
        var customers = Customer.list(listParams);
        if (!customers.getData().isEmpty()) {
            return customers.getData().get(0).getId();
        }
 
        // Tạo customer mới
        CustomerCreateParams createParams = CustomerCreateParams.builder()
                .setEmail(user.getEmail())
                .setName(user.getName())
                .putMetadata("user_id", user.getId().toString())
                .build();
 
        Customer customer = Customer.create(createParams);
        return customer.getId();
    }
}

Controller

// src/main/java/com/videoplatform/api/controller/SubscriptionController.java
package com.videoplatform.api.controller;
 
import com.videoplatform.api.dto.request.CheckoutRequest;
import com.videoplatform.api.dto.response.ApiResponse;
import com.videoplatform.api.dto.response.SubscriptionResponse;
import com.videoplatform.api.entity.*;
import com.videoplatform.api.exception.ResourceNotFoundException;
import com.videoplatform.api.repository.SubscriptionRepository;
import com.videoplatform.api.repository.UserRepository;
import com.videoplatform.api.service.StripeService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
 
import java.util.Map;
 
@RestController
@RequestMapping("/api/subscriptions")
public class SubscriptionController {
 
    private final StripeService stripeService;
    private final SubscriptionRepository subscriptionRepository;
    private final UserRepository userRepository;
 
    public SubscriptionController(
            StripeService stripeService,
            SubscriptionRepository subscriptionRepository,
            UserRepository userRepository) {
        this.stripeService = stripeService;
        this.subscriptionRepository = subscriptionRepository;
        this.userRepository = userRepository;
    }
 
    @PostMapping("/checkout")
    public ResponseEntity<ApiResponse<Map<String, String>>> createCheckout(
            @RequestBody CheckoutRequest request,
            @AuthenticationPrincipal UserDetails userDetails) throws Exception {
 
        User user = findUser(userDetails);
 
        // Kiểm tra user đã có subscription active chưa
        subscriptionRepository.findByUserAndStatus(user, SubscriptionStatus.ACTIVE)
                .ifPresent(sub -> {
                    throw new IllegalStateException("Bạn đã có subscription active");
                });
 
        String checkoutUrl = stripeService.createCheckoutSession(user, request.planType());
 
        return ResponseEntity.ok(
            ApiResponse.success(Map.of("url", checkoutUrl))
        );
    }
 
    @GetMapping("/current")
    public ResponseEntity<ApiResponse<SubscriptionResponse>> getCurrentSubscription(
            @AuthenticationPrincipal UserDetails userDetails) {
 
        User user = findUser(userDetails);
 
        SubscriptionResponse response = subscriptionRepository
                .findByUserAndStatusIn(user,
                    java.util.List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .map(sub -> new SubscriptionResponse(
                        sub.getPlanType().name(),
                        sub.getStatus().name(),
                        sub.getCurrentPeriodEnd(),
                        sub.isCancelAtPeriodEnd()
                ))
                .orElse(null);
 
        return ResponseEntity.ok(ApiResponse.success(response));
    }
 
    @PostMapping("/portal")
    public ResponseEntity<ApiResponse<Map<String, String>>> createPortalSession(
            @AuthenticationPrincipal UserDetails userDetails) throws Exception {
 
        User user = findUser(userDetails);
 
        Subscription subscription = subscriptionRepository
                .findByUserAndStatusIn(user,
                    java.util.List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy subscription active"));
 
        String portalUrl = stripeService.createPortalSession(
                subscription.getStripeCustomerId()
        );
 
        return ResponseEntity.ok(
            ApiResponse.success(Map.of("url", portalUrl))
        );
    }
 
    private User findUser(UserDetails userDetails) {
        return userRepository.findByEmail(userDetails.getUsername())
                .orElseThrow(() -> new ResourceNotFoundException("Không tìm thấy user"));
    }
}

DTO

// src/main/java/com/videoplatform/api/dto/request/CheckoutRequest.java
package com.videoplatform.api.dto.request;
 
import com.videoplatform.api.entity.PlanType;
 
public record CheckoutRequest(
    PlanType planType
) {}
// src/main/java/com/videoplatform/api/dto/response/SubscriptionResponse.java
package com.videoplatform.api.dto.response;
 
import java.time.LocalDateTime;
 
public record SubscriptionResponse(
    String planType,
    String status,
    LocalDateTime currentPeriodEnd,
    boolean cancelAtPeriodEnd
) {}

Webhook Handler

Webhook là xương sống của Stripe integration. Chúng cho bạn biết khi nào payment thành công, subscription renew, card bị fail, và user cancel. Bạn bắt buộc phải handle các event này — nếu không database sẽ không đồng bộ được.

Webhook Controller

// src/main/java/com/videoplatform/api/controller/StripeWebhookController.java
package com.videoplatform.api.controller;
 
import com.stripe.exception.SignatureVerificationException;
import com.stripe.model.*;
import com.stripe.net.Webhook;
import com.videoplatform.api.config.StripeConfig;
import com.videoplatform.api.service.SubscriptionWebhookService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping("/api/webhooks")
public class StripeWebhookController {
 
    private static final Logger log = LoggerFactory.getLogger(StripeWebhookController.class);
 
    private final StripeConfig config;
    private final SubscriptionWebhookService webhookService;
 
    public StripeWebhookController(StripeConfig config, SubscriptionWebhookService webhookService) {
        this.config = config;
        this.webhookService = webhookService;
    }
 
    @PostMapping("/stripe")
    public ResponseEntity<String> handleWebhook(
            @RequestBody String payload,
            @RequestHeader("Stripe-Signature") String sigHeader) {
 
        Event event;
 
        // 1. Verify webhook signature
        try {
            event = Webhook.constructEvent(payload, sigHeader, config.getWebhookSecret());
        } catch (SignatureVerificationException e) {
            log.error("Webhook signature verification failed: {}", e.getMessage());
            return ResponseEntity.badRequest().body("Invalid signature");
        }
 
        // 2. Handle event
        String eventType = event.getType();
        log.info("Nhận Stripe event: {} ({})", eventType, event.getId());
 
        try {
            switch (eventType) {
                case "checkout.session.completed" -> {
                    var session = (com.stripe.model.checkout.Session)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handleCheckoutCompleted(session);
                }
                case "customer.subscription.updated" -> {
                    var subscription = (com.stripe.model.Subscription)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handleSubscriptionUpdated(subscription);
                }
                case "customer.subscription.deleted" -> {
                    var subscription = (com.stripe.model.Subscription)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handleSubscriptionDeleted(subscription);
                }
                case "invoice.payment_failed" -> {
                    var invoice = (Invoice)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handlePaymentFailed(invoice);
                }
                case "invoice.payment_succeeded" -> {
                    var invoice = (Invoice)
                            event.getDataObjectDeserializer().getObject().orElseThrow();
                    webhookService.handlePaymentSucceeded(invoice);
                }
                default -> log.info("Event type chưa handle: {}", eventType);
            }
        } catch (Exception e) {
            log.error("Lỗi xử lý webhook event {}: {}", eventType, e.getMessage(), e);
            // Vẫn return 200 — Stripe sẽ retry nếu nhận 4xx/5xx
            // Mình không muốn Stripe retry liên tục nếu handler có bug
        }
 
        // Luôn return 200 để acknowledge receipt
        return ResponseEntity.ok("OK");
    }
}

Webhook Service

// src/main/java/com/videoplatform/api/service/SubscriptionWebhookService.java
package com.videoplatform.api.service;
 
import com.stripe.model.Invoice;
import com.stripe.model.checkout.Session;
import com.videoplatform.api.entity.*;
import com.videoplatform.api.repository.PaymentHistoryRepository;
import com.videoplatform.api.repository.SubscriptionRepository;
import com.videoplatform.api.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Map;
 
@Service
public class SubscriptionWebhookService {
 
    private static final Logger log = LoggerFactory.getLogger(SubscriptionWebhookService.class);
 
    private final SubscriptionRepository subscriptionRepository;
    private final UserRepository userRepository;
    private final PaymentHistoryRepository paymentHistoryRepository;
 
    public SubscriptionWebhookService(
            SubscriptionRepository subscriptionRepository,
            UserRepository userRepository,
            PaymentHistoryRepository paymentHistoryRepository) {
        this.subscriptionRepository = subscriptionRepository;
        this.userRepository = userRepository;
        this.paymentHistoryRepository = paymentHistoryRepository;
    }
 
    @Transactional
    public void handleCheckoutCompleted(Session session) {
        // Chỉ handle subscription checkout
        if (!"subscription".equals(session.getMode())) return;
 
        String stripeSubscriptionId = session.getSubscription();
        String stripeCustomerId = session.getCustomer();
 
        // Lấy metadata
        // Lưu ý: metadata nằm trên subscription, không phải session
        try {
            var stripeSub = com.stripe.model.Subscription.retrieve(stripeSubscriptionId);
            Map<String, String> metadata = stripeSub.getMetadata();
 
            Long userId = Long.parseLong(metadata.get("user_id"));
            PlanType planType = PlanType.valueOf(metadata.get("plan_type"));
 
            User user = userRepository.findById(userId)
                    .orElseThrow(() -> new RuntimeException("Không tìm thấy user: " + userId));
 
            // Kiểm tra subscription đã tồn tại chưa (idempotency)
            if (subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId).isPresent()) {
                log.info("Subscription đã tồn tại: {}", stripeSubscriptionId);
                return;
            }
 
            Subscription subscription = new Subscription();
            subscription.setUser(user);
            subscription.setStripeSubscriptionId(stripeSubscriptionId);
            subscription.setStripeCustomerId(stripeCustomerId);
            subscription.setPlanType(planType);
            subscription.setStatus(SubscriptionStatus.ACTIVE);
            subscription.setCurrentPeriodStart(
                    toLocalDateTime(stripeSub.getCurrentPeriodStart()));
            subscription.setCurrentPeriodEnd(
                    toLocalDateTime(stripeSub.getCurrentPeriodEnd()));
 
            subscriptionRepository.save(subscription);
 
            log.info("Đã tạo subscription cho user {} ({})", userId, planType);
        } catch (Exception e) {
            log.error("Lỗi xử lý checkout completed: {}", e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }
 
    @Transactional
    public void handleSubscriptionUpdated(com.stripe.model.Subscription stripeSub) {
        subscriptionRepository.findByStripeSubscriptionId(stripeSub.getId())
                .ifPresent(subscription -> {
                    // Update period date
                    subscription.setCurrentPeriodStart(
                            toLocalDateTime(stripeSub.getCurrentPeriodStart()));
                    subscription.setCurrentPeriodEnd(
                            toLocalDateTime(stripeSub.getCurrentPeriodEnd()));
 
                    // Update status
                    subscription.setStatus(mapStripeStatus(stripeSub.getStatus()));
 
                    // Track cancellation
                    subscription.setCancelAtPeriodEnd(
                            Boolean.TRUE.equals(stripeSub.getCancelAtPeriodEnd()));
 
                    subscription.setUpdatedAt(LocalDateTime.now());
                    subscriptionRepository.save(subscription);
 
                    log.info("Đã update subscription {}: status={}, cancelAtPeriodEnd={}",
                            stripeSub.getId(), stripeSub.getStatus(),
                            stripeSub.getCancelAtPeriodEnd());
                });
    }
 
    @Transactional
    public void handleSubscriptionDeleted(com.stripe.model.Subscription stripeSub) {
        subscriptionRepository.findByStripeSubscriptionId(stripeSub.getId())
                .ifPresent(subscription -> {
                    subscription.setStatus(SubscriptionStatus.EXPIRED);
                    subscription.setUpdatedAt(LocalDateTime.now());
                    subscriptionRepository.save(subscription);
 
                    log.info("Subscription đã expired: {}", stripeSub.getId());
                });
    }
 
    @Transactional
    public void handlePaymentFailed(Invoice invoice) {
        String subscriptionId = invoice.getSubscription();
        if (subscriptionId == null) return;
 
        subscriptionRepository.findByStripeSubscriptionId(subscriptionId)
                .ifPresent(subscription -> {
                    subscription.setStatus(SubscriptionStatus.PAST_DUE);
                    subscription.setUpdatedAt(LocalDateTime.now());
                    subscriptionRepository.save(subscription);
 
                    log.warn("Payment thất bại cho subscription {}", subscriptionId);
                    // TODO: Gửi email thông báo cho user
                });
    }
 
    @Transactional
    public void handlePaymentSucceeded(Invoice invoice) {
        // Ghi lại payment history
        if (invoice.getPaymentIntent() != null) {
            if (paymentHistoryRepository.findByStripePaymentIntent(
                    invoice.getPaymentIntent()).isEmpty()) {
 
                PaymentHistory payment = new PaymentHistory();
 
                // Tìm user từ subscription
                if (invoice.getSubscription() != null) {
                    subscriptionRepository.findByStripeSubscriptionId(invoice.getSubscription())
                            .ifPresent(sub -> payment.setUser(sub.getUser()));
                }
 
                payment.setStripePaymentIntent(invoice.getPaymentIntent());
                payment.setAmount(invoice.getAmountPaid().intValue());
                payment.setCurrency(invoice.getCurrency());
                payment.setStatus("succeeded");
 
                paymentHistoryRepository.save(payment);
            }
        }
 
        // Kích hoạt lại subscription nếu đang past_due
        if (invoice.getSubscription() != null) {
            subscriptionRepository.findByStripeSubscriptionId(invoice.getSubscription())
                    .filter(sub -> sub.getStatus() == SubscriptionStatus.PAST_DUE)
                    .ifPresent(subscription -> {
                        subscription.setStatus(SubscriptionStatus.ACTIVE);
                        subscription.setUpdatedAt(LocalDateTime.now());
                        subscriptionRepository.save(subscription);
 
                        log.info("Đã kích hoạt lại subscription sau payment thành công: {}",
                                invoice.getSubscription());
                    });
        }
    }
 
    private SubscriptionStatus mapStripeStatus(String stripeStatus) {
        return switch (stripeStatus) {
            case "active", "trialing" -> SubscriptionStatus.ACTIVE;
            case "past_due" -> SubscriptionStatus.PAST_DUE;
            case "canceled", "unpaid" -> SubscriptionStatus.CANCELED;
            default -> SubscriptionStatus.EXPIRED;
        };
    }
 
    private LocalDateTime toLocalDateTime(Long epochSeconds) {
        return LocalDateTime.ofInstant(
                Instant.ofEpochSecond(epochSeconds), ZoneOffset.UTC);
    }
}

Flow Event Webhook

Đây là flow xảy ra cho mỗi subscription lifecycle event:


Vòng Đời Subscription

Happy Path

  1. User click "Subscribe Monthly" → redirect tới Stripe Checkout
  2. User nhập thông tin thanh toán → checkout.session.completed → subscription được tạo (ACTIVE)
  3. 30 ngày sau → Stripe auto-charge → invoice.payment_succeeded → period được update
  4. Lặp lại mỗi tháng

Cancellation

  1. User mở Customer Portal → click "Cancel Subscription"
  2. customer.subscription.updated → cancelAtPeriodEnd: true
  3. User vẫn giữ access cho tới hết period hiện tại
  4. Period hết → customer.subscription.deleted → status: EXPIRED

Payment Thất Bại

  1. Payment fail → invoice.payment_failed → status: PAST_DUE
  2. Stripe retry 3 lần trong ~2 tuần (configurable trong Stripe Dashboard)
  3. Nếu payment thành công → invoice.payment_succeeded → status: ACTIVE
  4. Nếu tất cả retry fail → customer.subscription.deleted → status: EXPIRED

Access Gating

Update SubscriptionService

Chúng ta đã tạo SubscriptionService cơ bản trong VID-8. Giờ mở rộng nó:

// src/main/java/com/videoplatform/api/service/SubscriptionService.java
package com.videoplatform.api.service;
 
import com.videoplatform.api.entity.Subscription;
import com.videoplatform.api.entity.SubscriptionStatus;
import com.videoplatform.api.entity.User;
import com.videoplatform.api.repository.SubscriptionRepository;
import com.videoplatform.api.repository.UserRepository;
import org.springframework.stereotype.Service;
 
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
 
@Service
public class SubscriptionService {
 
    private final SubscriptionRepository subscriptionRepository;
    private final UserRepository userRepository;
 
    public SubscriptionService(
            SubscriptionRepository subscriptionRepository,
            UserRepository userRepository) {
        this.subscriptionRepository = subscriptionRepository;
        this.userRepository = userRepository;
    }
 
    public boolean hasActiveSubscription(String email) {
        User user = userRepository.findByEmail(email).orElse(null);
        if (user == null) return false;
 
        return subscriptionRepository
                .findByUserAndStatusIn(user,
                    List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .filter(sub -> sub.getCurrentPeriodEnd().isAfter(LocalDateTime.now()))
                .isPresent();
    }
 
    public Optional<Subscription> getActiveSubscription(String email) {
        User user = userRepository.findByEmail(email).orElse(null);
        if (user == null) return Optional.empty();
 
        return subscriptionRepository
                .findByUserAndStatusIn(user,
                    List.of(SubscriptionStatus.ACTIVE, SubscriptionStatus.PAST_DUE))
                .filter(sub -> sub.getCurrentPeriodEnd().isAfter(LocalDateTime.now()));
    }
}

Lưu ý chúng ta cho phép status PAST_DUE vẫn access content. Tại sao? Vì Stripe vẫn đang retry payment — user có thể fix thông tin card. Cắt access ngay lập tức là trải nghiệm tệ. Họ được grace period cho tới khi Stripe bỏ cuộc (thường ~2 tuần).

Access Check trong StreamController

StreamController từ VID-8 đã gọi subscriptionService.hasActiveSubscription(). Không cần thay đổi gì — access gating đã có sẵn. Nếu user không có subscription active (hoặc past_due trong period), họ nhận 403 trên stream endpoint.


Frontend: Pricing Page

Pricing Component

// web/src/app/(public)/pricing/page.tsx
"use client";
 
import { useState } from "react";
import { Check } from "lucide-react";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
const plans = [
  {
    name: "Monthly",
    price: "$9.99",
    period: "/tháng",
    planType: "MONTHLY",
    features: [
      "Truy cập tất cả khóa học",
      "Streaming video HD (720p)",
      "Theo dõi tiến độ học",
      "Nội dung mới mỗi tuần",
      "Hủy bất cứ lúc nào",
    ],
  },
  {
    name: "Yearly",
    price: "$99.99",
    period: "/năm",
    planType: "YEARLY",
    badge: "Tiết kiệm 17%",
    features: [
      "Tất cả trong gói Monthly",
      "Miễn phí 2 tháng",
      "Hỗ trợ ưu tiên",
      "Early access khóa học mới",
      "Hủy bất cứ lúc nào",
    ],
  },
];
 
export default function PricingPage() {
  const [loading, setLoading] = useState<string | null>(null);
 
  const handleSubscribe = async (planType: string) => {
    setLoading(planType);
 
    try {
      const token = localStorage.getItem("accessToken");
 
      if (!token) {
        // Redirect tới login
        window.location.href = "/login?redirect=/pricing";
        return;
      }
 
      const res = await fetch(`${API_BASE}/api/subscriptions/checkout`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({ planType }),
      });
 
      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.message || "Không thể tạo checkout session");
      }
 
      const data = await res.json();
      // Redirect tới Stripe Checkout
      window.location.href = data.data.url;
    } catch (error) {
      console.error("Checkout error:", error);
      alert(error instanceof Error ? error.message : "Có lỗi xảy ra");
    } finally {
      setLoading(null);
    }
  };
 
  return (
    <div className="max-w-4xl mx-auto py-16 px-4">
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold mb-4">Bảng Giá Đơn Giản, Minh Bạch</h1>
        <p className="text-xl text-muted-foreground">
          Truy cập không giới hạn tất cả khóa học. Hủy bất cứ lúc nào.
        </p>
      </div>
 
      <div className="grid md:grid-cols-2 gap-8">
        {plans.map((plan) => (
          <div
            key={plan.planType}
            className={`relative rounded-2xl border p-8 ${
              plan.badge
                ? "border-primary shadow-lg ring-1 ring-primary"
                : "border-border"
            }`}
          >
            {plan.badge && (
              <span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary
                text-primary-foreground text-sm font-medium px-3 py-1 rounded-full">
                {plan.badge}
              </span>
            )}
 
            <div className="mb-6">
              <h2 className="text-2xl font-bold">{plan.name}</h2>
              <div className="mt-2">
                <span className="text-4xl font-bold">{plan.price}</span>
                <span className="text-muted-foreground">{plan.period}</span>
              </div>
            </div>
 
            <ul className="space-y-3 mb-8">
              {plan.features.map((feature) => (
                <li key={feature} className="flex items-center gap-2">
                  <Check className="h-5 w-5 text-primary shrink-0" />
                  <span>{feature}</span>
                </li>
              ))}
            </ul>
 
            <button
              onClick={() => handleSubscribe(plan.planType)}
              disabled={loading !== null}
              className={`w-full py-3 px-6 rounded-lg font-medium transition-colors ${
                plan.badge
                  ? "bg-primary text-primary-foreground hover:bg-primary/90"
                  : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
              } disabled:opacity-50 disabled:cursor-not-allowed`}
            >
              {loading === plan.planType ? "Đang tải..." : "Bắt Đầu"}
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

Quản Lý Subscription

// web/src/components/SubscriptionStatus.tsx
"use client";
 
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
 
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
 
interface SubscriptionInfo {
  planType: string;
  status: string;
  currentPeriodEnd: string;
  cancelAtPeriodEnd: boolean;
}
 
export function SubscriptionStatus() {
  const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    async function loadSubscription() {
      try {
        const token = localStorage.getItem("accessToken");
        const res = await fetch(`${API_BASE}/api/subscriptions/current`, {
          headers: { Authorization: `Bearer ${token}` },
        });
        if (res.ok) {
          const data = await res.json();
          setSubscription(data.data);
        }
      } catch {
        // Không có subscription
      } finally {
        setLoading(false);
      }
    }
    loadSubscription();
  }, []);
 
  if (loading || !subscription) return null;
 
  const endDate = new Date(subscription.currentPeriodEnd).toLocaleDateString();
 
  const handleManage = async () => {
    try {
      const token = localStorage.getItem("accessToken");
      const res = await fetch(`${API_BASE}/api/subscriptions/portal`, {
        method: "POST",
        headers: { Authorization: `Bearer ${token}` },
      });
      const data = await res.json();
      window.location.href = data.data.url;
    } catch (error) {
      console.error("Portal error:", error);
    }
  };
 
  return (
    <div className="rounded-lg border p-4">
      <div className="flex items-center justify-between">
        <div>
          <p className="font-medium">
            Gói {subscription.planType}
            <Badge variant="outline" className="ml-2">
              {subscription.status}
            </Badge>
          </p>
          {subscription.cancelAtPeriodEnd && (
            <p className="text-sm text-muted-foreground mt-1">
              Quyền truy cập đến {endDate}
            </p>
          )}
          {!subscription.cancelAtPeriodEnd && (
            <p className="text-sm text-muted-foreground mt-1">
              Gia hạn vào {endDate}
            </p>
          )}
        </div>
        <button
          onClick={handleManage}
          className="text-sm text-primary hover:underline"
        >
          Quản lý subscription
        </button>
      </div>
    </div>
  );
}

Bảo Mật

1. Verify Webhook Signature

Luôn verify webhook signature. Nếu không, bất kỳ ai cũng có thể gửi fake event tới webhook endpoint:

// Đây là bắt buộc — đừng bao giờ bỏ qua signature verification
try {
    event = Webhook.constructEvent(payload, sigHeader, config.getWebhookSecret());
} catch (SignatureVerificationException e) {
    return ResponseEntity.badRequest().body("Invalid signature");
}

2. Loại Trừ Webhook Khỏi CSRF/Auth

Webhook endpoint cần accept POST request từ Stripe (không có auth token):

// Trong SecurityConfig.java — thêm webhook vào public endpoint
.requestMatchers("/api/webhooks/**").permitAll()

3. Idempotency

Stripe có thể gửi cùng một webhook event nhiều lần (do retry). Luôn kiểm tra xem bạn đã xử lý event chưa trước khi hành động:

// Kiểm tra subscription đã tồn tại chưa
if (subscriptionRepository.findByStripeSubscriptionId(stripeSubscriptionId).isPresent()) {
    log.info("Subscription đã tồn tại — bỏ qua");
    return;
}

4. Raw Request Body Cho Signature Verification

Spring Boot có thể parse request body thành JSON trước khi controller nhận được. Để verify webhook signature, bạn cần body raw. Dùng @RequestBody String payload — không phải object đã deserialize:

@PostMapping("/stripe")
public ResponseEntity<String> handleWebhook(
        @RequestBody String payload,  // Raw body, không parse
        @RequestHeader("Stripe-Signature") String sigHeader) {

Testing

1. Test Webhook Local Với Stripe CLI

# Cài Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Đăng nhập
stripe login
 
# Forward webhook tới local server
stripe listen --forward-to localhost:8080/api/webhooks/stripe
# Ghi lại webhook signing secret nó output — dùng làm STRIPE_WEBHOOK_SECRET

2. Trigger Test Event

# Trigger checkout completed event
stripe trigger checkout.session.completed
 
# Trigger subscription update
stripe trigger customer.subscription.updated
 
# Trigger payment failure
stripe trigger invoice.payment_failed

3. Test Checkout Flow

# Tạo checkout session
curl -X POST http://localhost:8080/api/subscriptions/checkout \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"planType": "MONTHLY"}'
 
# Response:
{ "data": { "url": "https://checkout.stripe.com/c/pay/cs_test_..." } }

Mở URL trong browser. Dùng test card number của Stripe:

Card NumberKịch Bản
4242 4242 4242 4242Payment thành công
4000 0000 0000 0341Card bị từ chối
4000 0000 0000 3220Yêu cầu 3D Secure

4. Kiểm Tra Subscription Đã Tạo

# Kiểm tra subscription hiện tại
curl -s http://localhost:8080/api/subscriptions/current \
  -H "Authorization: Bearer ${TOKEN}" | jq
 
# Response:
{
  "data": {
    "planType": "MONTHLY",
    "status": "ACTIVE",
    "currentPeriodEnd": "2026-04-23T00:00:00",
    "cancelAtPeriodEnd": false
  }
}

5. Test Access Gating

# Không có subscription — 403
curl -v http://localhost:8080/api/lessons/1/stream \
  -H "Authorization: Bearer ${UNSUBSCRIBED_TOKEN}"
# < HTTP/1.1 403 Forbidden
 
# Có subscription — nhận stream URL
curl -v http://localhost:8080/api/lessons/1/stream \
  -H "Authorization: Bearer ${SUBSCRIBED_TOKEN}"
# < HTTP/1.1 200 OK

Environment Variable

# .env
STRIPE_SECRET_KEY=sk_test_...        # Từ Stripe Dashboard
STRIPE_WEBHOOK_SECRET=whsec_...      # Từ Stripe CLI hoặc Dashboard
STRIPE_PRICE_MONTHLY=price_...       # Monthly price ID
STRIPE_PRICE_YEARLY=price_...        # Yearly price ID
APP_URL=http://localhost:3000        # Frontend URL

Sai Lầm Thường Gặp

1. Tạo Subscription Khi Redirect Thay Vì Webhook

// SAI — tạo subscription khi user quay lại từ Stripe
@GetMapping("/success")
public void handleSuccess(@RequestParam String session_id) {
    // User tới success page — tạo subscription
    createSubscription(session_id);
    // Vấn đề: user có thể đóng tab trước khi tới page này!
}
 
// ĐÚNG — tạo subscription trong webhook handler
@PostMapping("/webhooks/stripe")
public void handleWebhook(String payload, String signature) {
    // Stripe gửi server-to-server — guaranteed delivery
    handleCheckoutCompleted(session);
}

2. Không Handle Duplicate Webhook Event

Stripe có thể gửi cùng event 2-3 lần. Nếu không có idempotency check, bạn sẽ tạo duplicate subscription:

// Luôn kiểm tra trước khi tạo
if (subscriptionRepository.findByStripeSubscriptionId(id).isPresent()) {
    return; // Đã xử lý rồi
}

3. Cắt Access Ngay Khi Past Due

// SAI — block access ngay lập tức
if (status == SubscriptionStatus.PAST_DUE) {
    throw new AccessDeniedException("Payment thất bại");
}
 
// ĐÚNG — cho phép access trong grace period
if (status == SubscriptionStatus.ACTIVE || status == SubscriptionStatus.PAST_DUE) {
    if (subscription.getCurrentPeriodEnd().isAfter(LocalDateTime.now())) {
        // Vẫn trong period đã trả tiền — cho phép access
        return true;
    }
}

4. Bỏ Qua Webhook Signature Verification

// SAI — bất kỳ ai cũng fake được webhook event
@PostMapping("/webhooks/stripe")
public void handle(@RequestBody String payload) {
    Event event = Event.GSON.fromJson(payload, Event.class);
    // Attacker có thể tự cấp subscription miễn phí!
}
 
// ĐÚNG — verify signature
Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);

Tiếp Theo Là Gì?

Subscription đã hoạt động — user có thể trả tiền, và chỉ subscriber mới stream được. Trong Bài #11, chúng ta sẽ build course catalog công khai:

  • Server-side rendered course page với generateMetadata()
  • JSON-LD schema cho SEO
  • Free preview lesson cho non-subscriber
  • Enrollment flow (duyệt → preview → subscribe → xem)
  • Responsive catalog grid với course card

Đến lúc biến platform này thành một sản phẩm thực sự.

Series: Xây Dựng Video Streaming Platform
Trước đó: Phase 8: Video Player & Progress Tracking
Tiếp theo: Phase 10: Public Course Catalog & SEO

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.