# Credit-Per-Item: Design Document ## Overview This feature implements credit enforcement across the news processing pipeline. Credits are deducted when news items undergo AI processing, and all AI-dependent operations are blocked when credits reach zero. **Key Design Principles:** - Block at scheduler level for efficiency (batch queries, not per-item checks) - Defense-in-depth: safety net in `TrackedAiService` as last resort - Non-destructive blocking: items stay in current status, resume when credits replenished - Single credit deduction point: `ProcessingWorker` after successful AI enrichment ## Architecture ``` ┌─────────────────────────────────────────────────────────────────────────────────┐ │ CREDIT ENFORCEMENT LAYERS │ ├─────────────────────────────────────────────────────────────────────────────────┤ │ │ │ LAYER 1: SCHEDULER BLOCKING (Primary) │ │ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │ │ │ FeedSchedulerJob │ │ProcessingSchedulerJob│ │BriefingSchedulerJob│ │ │ │ ─────────────────── │ │ ─────────────────── │ │ ─────────────────── │ │ │ │ Skip sources for │ │ Skip items for │ │ Skip briefings for │ │ │ │ users with 0 credits│ │ users with 0 credits│ │ users with 0 credits│ │ │ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘ │ │ │ │ LAYER 2: LISTENER BLOCKING │ │ ┌─────────────────────┐ │ │ │EmailIngestionListener│ │ │ │ ─────────────────── │ │ │ │ Check credits before │ │ │ │ AI extraction │ │ │ └─────────────────────┘ │ │ │ │ LAYER 3: API BLOCKING │ │ ┌─────────────────────┐ │ │ │DayBriefController │ │ │ │ ─────────────────── │ │ │ │ Check credits before │ │ │ │ manual execution │ │ │ └─────────────────────┘ │ │ │ │ LAYER 4: SAFETY NET (Defense-in-Depth) │ │ ┌─────────────────────┐ │ │ │ TrackedAiService │ │ │ │ ─────────────────── │ │ │ │ Throw exception if │ │ │ │ AI call with 0 credit│ │ │ └─────────────────────┘ │ │ │ │ CREDIT DEDUCTION (existing SubscriptionService) │ │ ┌─────────────────────┐ │ │ │ ProcessingWorker │ ──> SubscriptionService.useCredits() ──> CreditUsageLog │ │ └─────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────────────┘ ``` ## Components and Interfaces ### 1. SubscriptionService (Extended) Extend existing service with additional methods for credit checking. **File:** `backend/src/main/java/be/transcode/morningdeck/server/core/service/SubscriptionService.java` **Existing methods (no changes):** - `useCredits(User user, int credits)` - deducts credits and logs to `CreditUsageLog` - `createSubscription()`, `upgradePlan()`, `checkAndRenewSubscriptions()` **New methods to add:** ```java /** * Check if user has any credits (> 0). * @param userId User ID * @return true if user has at least 1 credit */ public boolean hasCredits(UUID userId) { return subscriptionRepository.findCreditsBalanceByUserId(userId) .map(balance -> balance > 0) .orElse(false); } /** * Get credit balance for user. * @return credit balance or 0 if subscription not found */ public int getCreditsBalance(UUID userId) { return subscriptionRepository.findCreditsBalanceByUserId(userId).orElse(0); } /** * Get set of user IDs that have credits > 0. * Used by schedulers for efficient batch filtering. */ public Set getUserIdsWithCredits() { return subscriptionRepository.findUserIdsWithCredits(); } /** * Overload of useCredits that accepts userId instead of User entity. * Also triggers no-credits notification when balance hits zero. */ @Transactional public boolean useCredits(UUID userId, int credits) { User user = userRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("User not found")); boolean success = useCredits(user, credits); // Send notification if balance just hit zero if (success) { Subscription sub = user.getSubscription(); if (sub.getCreditsBalance() == 0) { sendNoCreditsNotification(user); } } return success; } private void sendNoCreditsNotification(User user) { try { emailService.sendNoCreditsEmail(user.getEmail(), user.getName(), domain); log.info("Sent no-credits notification to user {}", user.getId()); } catch (Exception e) { log.error("Failed to send no-credits notification to user {}: {}", user.getId(), e.getMessage()); } } ``` ### 2. InsufficientCreditsException (New) **File:** `backend/src/main/java/be/transcode/morningdeck/server/core/exception/InsufficientCreditsException.java` ```java public class InsufficientCreditsException extends RuntimeException { private final UUID userId; private final int creditsRequired; private final int creditsAvailable; public InsufficientCreditsException(UUID userId, int required, int available) { super(String.format("Insufficient credits for user %s: required=%d, available=%d", userId, required, available)); this.userId = userId; this.creditsRequired = required; this.creditsAvailable = available; } } ``` ### 3. Modified Components #### 3.1 TrackedAiService (Safety Net) Add credit check at the start of `trackCall()` method: ```java private T trackCall(AiFeature feature, Supplier> call) { UUID userId = AiUsageContext.getUserId(); // Safety net: verify credits if user context is set if (userId != null) { int balance = subscriptionService.getCreditsBalance(userId); if (balance <= 0) { log.error("AI call attempted with zero credits: userId={} feature={}", userId, feature); throw new InsufficientCreditsException(userId, 1, balance); } } // ... existing tracking logic } ``` #### 3.2 ProcessingWorker (Credit Deduction) Add credit deduction after successful AI processing: ```java private void doProcess(NewsItem item) { // ... existing content preparation ... UUID userId = item.getSource().getDayBrief().getUserId(); try { AiUsageContext.setUserId(userId); // Enrich and score EnrichmentWithScoreResult result = aiService.enrichWithScore(...); // Apply results to item item.setSummary(result.summary()); // ... set tags, score, etc. // Deduct credit after successful processing boolean deducted = subscriptionService.useCredits(userId, 1); if (!deducted) { // This should not happen due to scheduler-level checks log.error("Failed to deduct credit after AI processing: userId={}", userId); throw new InsufficientCreditsException(userId, 1, 0); } } finally { AiUsageContext.clear(); } } ``` #### 3.3 ProcessingSchedulerJob Filter out items for users with zero credits: ```java @Scheduled(fixedRateString = "${application.jobs.news-processing.interval:60000}") public void scheduleProcessing() { if (!processingQueue.canAccept()) { log.warn("Queue full, skipping scheduling cycle"); return; } // Get users with available credits Set usersWithCredits = subscriptionService.getUserIdsWithCredits(); if (usersWithCredits.isEmpty()) { log.debug("No users with credits available"); return; } // Find items for users with credits List candidates = newsItemRepository.findItemsForProcessingByUserIds( usersWithCredits, PageRequest.of(0, batchSize)); // ... rest of scheduling logic } ``` #### 3.4 FeedSchedulerJob Filter out sources for users with zero credits: ```java @Scheduled(fixedRateString = "${application.jobs.feed-scheduling.interval:60000}") @Transactional public void scheduleFeeds() { if (!fetchQueue.canAccept()) { return; } // Get users with credits Set usersWithCredits = subscriptionService.getUserIdsWithCredits(); // Filter candidates by users with credits List dueForRefresh = candidates.stream() .filter(s -> isDueForRefresh(s, now)) .filter(s -> usersWithCredits.contains(s.getDayBrief().getUserId())) .limit(batchSize) .toList(); // ... rest of scheduling logic } ``` #### 3.5 BriefingSchedulerJob Filter out briefings for users with zero credits: ```java @Scheduled(fixedRateString = "${application.jobs.briefing-execution.interval:60000}") public void scheduleBriefings() { if (!briefingQueue.canAccept()) { return; } // Get users with credits Set usersWithCredits = subscriptionService.getUserIdsWithCredits(); List dueBriefings = candidateBriefings.stream() .filter(briefing -> isBriefingDue(briefing, nowUtc)) .filter(briefing -> usersWithCredits.contains(briefing.getUserId())) .toList(); // ... rest of scheduling logic } ``` #### 3.6 EmailIngestionListener Check credits before AI extraction: ```java private void processEmail(Source source, EmailMessage email) { UUID userId = source.getDayBrief().getUserId(); // Check credits before AI extraction if (!subscriptionService.hasCredits(userId)) { log.warn("Email received but user {} has no credits. Stored but not processed: messageId={}", userId, email.getMessageId()); markRawEmailAsUnprocessed(source, email); return; } try { AiUsageContext.setUserId(userId); List items = aiService.extractFromEmail(email.getSubject(), email.getContent()); // ... create news items } finally { AiUsageContext.clear(); } } ``` #### 3.7 DayBriefController Check credits for manual briefing execution: ```java @PostMapping("/{id}/execute") public ResponseEntity executeBriefing( @AuthenticationPrincipal UserDetails userDetails, @PathVariable UUID id) { User user = userService.getInternalUserByUsername(userDetails.getUsername()); // Check credits if (!subscriptionService.hasCredits(user.getId())) { throw new InsufficientCreditsException(user.getId(), 1, 0); } DayBrief dayBrief = dayBriefService.getDayBriefEntity(user.getId(), id); DailyReportDTO report = briefingWorker.executeManually(dayBrief); return ResponseEntity.ok(report); } ``` #### 3.8 GlobalExceptionHandler Handle `InsufficientCreditsException`: ```java @ExceptionHandler(InsufficientCreditsException.class) public ResponseEntity handleInsufficientCredits( InsufficientCreditsException ex, HttpServletRequest request) { return buildErrorResponse(HttpStatus.PAYMENT_REQUIRED, ex.getMessage(), request); } ``` ## Data Models ### Existing Models (No Changes) - **Subscription** - `creditsBalance`, `monthlyCredits` fields already exist - **CreditUsageLog** - Already tracks credit usage with `user`, `subscription`, `creditsUsed`, `usedAt` ### Modified Models #### RawEmail (Add Processing Status) For REQ-9 (optional email reprocessing), add a status field: ```java @Entity @Table(name = "raw_emails") public class RawEmail { // ... existing fields ... @Enumerated(EnumType.STRING) @Column(name = "processing_status", nullable = false) @Builder.Default private RawEmailStatus processingStatus = RawEmailStatus.PENDING; } public enum RawEmailStatus { PENDING, // Awaiting processing PROCESSED, // Successfully processed SKIPPED // Skipped due to insufficient credits } ``` **Migration:** `V__add_raw_email_status.sql` ```sql ALTER TABLE raw_emails ADD COLUMN processing_status VARCHAR(20) DEFAULT 'PROCESSED' NOT NULL; CREATE INDEX idx_raw_emails_status ON raw_emails(processing_status) WHERE processing_status = 'SKIPPED'; ``` ### New Repository Methods #### SubscriptionRepository ```java @Query("SELECT s.user.id FROM Subscription s WHERE s.creditsBalance > 0") Set findUserIdsWithCredits(); @Query("SELECT s.creditsBalance FROM Subscription s WHERE s.user.id = :userId") Optional findCreditsBalanceByUserId(@Param("userId") UUID userId); ``` #### NewsItemRepository ```java @Query(""" SELECT n FROM NewsItem n WHERE n.status = be.transcode.morningdeck.server.core.model.NewsItemStatus.NEW AND n.source.dayBrief.userId IN :userIds ORDER BY n.createdAt ASC """) List findItemsForProcessingByUserIds( @Param("userIds") Set userIds, Pageable pageable); ``` #### RawEmailRepository (for REQ-9) ```java List findByProcessingStatus(RawEmailStatus status); @Query("SELECT r FROM RawEmail r WHERE r.processingStatus = 'SKIPPED' AND r.source.dayBrief.userId = :userId") List findSkippedByUserId(@Param("userId") UUID userId); ``` ## Error Handling | Exception | HTTP Status | Trigger | |-----------|-------------|---------| | `InsufficientCreditsException` | 402 Payment Required | Manual briefing execution, safety net | | N/A (silent skip) | N/A | Scheduler skips items/sources/briefings | **Logging Strategy:** - `INFO`: Items/sources/briefings skipped due to zero credits - `WARN`: Email stored but not processed due to zero credits - `ERROR`: AI call attempted despite zero credits (safety net triggered) ## Testing Strategy ### Unit Tests | Component | Test Cases | |-----------|------------| | `SubscriptionService` | hasCredits(), useCredits(UUID), getUserIdsWithCredits(), notification trigger | | `ProcessingWorker` | Credit deduction after success, handling deduction failure | | `TrackedAiService` | Safety net throws exception when credits = 0 | ### Integration Tests | Test Class | Scenarios | |------------|-----------| | `CreditEnforcementIT` | End-to-end: create items, process, verify credit deduction | | `SchedulerCreditIT` | Scheduler skips users with zero credits | | `EmailIngestionCreditIT` | Email stored but not processed when credits = 0 | | `ManualExecutionCreditIT` | Manual briefing execution returns 402 when credits = 0 | ### Test Data Setup ```java // User with credits User userWithCredits = createUserWithSubscription(SubscriptionPlan.FREE); // 900 credits // User without credits User userNoCredits = createUserWithSubscription(SubscriptionPlan.FREE); userNoCredits.getSubscription().setCreditsBalance(0); ``` ## Performance Considerations ### Batch Credit Checking Instead of checking credits per-item in schedulers: ```java // BAD: N+1 queries for (NewsItem item : candidates) { UUID userId = item.getSource().getDayBrief().getUserId(); if (subscriptionService.hasCredits(userId)) { // Query per item enqueue(item); } } // GOOD: Single query for all user IDs with credits Set usersWithCredits = subscriptionService.getUserIdsWithCredits(); // One query for (NewsItem item : candidates) { UUID userId = item.getSource().getDayBrief().getUserId(); if (usersWithCredits.contains(userId)) { // In-memory lookup enqueue(item); } } ``` ### Index Considerations Existing indexes should suffice. The `subscriptions.credits_balance` column may benefit from a partial index: ```sql CREATE INDEX idx_subscriptions_has_credits ON subscriptions(user_id) WHERE credits_balance > 0; ``` ## Security Considerations ### Race Condition Prevention Credit deduction uses optimistic locking via `@Version`: ```java @Entity public class Subscription { @Version private Long version; // Already exists in JPA } ``` In `SubscriptionService.useCredits(UUID, int)`: ```java @Transactional public boolean useCredits(UUID userId, int credits) { Subscription sub = subscriptionRepository.findByUserId(userId) .orElseThrow(); if (sub.getCreditsBalance() < credits) { return false; } sub.setCreditsBalance(sub.getCreditsBalance() - credits); subscriptionRepository.save(sub); // OptimisticLockException if concurrent update // Log usage creditUsageLogRepository.save(CreditUsageLog.builder() .user(sub.getUser()) .subscription(sub) .creditsUsed(credits) .build()); // Check if balance hit zero - send notification if (sub.getCreditsBalance() == 0) { sendNoCreditsNotification(sub.getUser()); } return true; } ``` ### Input Validation - Credit deduction only accepts positive values - User ID validation via existing authentication ## Monitoring and Observability ### Metrics | Metric | Type | Description | |--------|------|-------------| | `credits.deducted.total` | Counter | Total credits deducted | | `credits.deducted.user` | Counter (tagged by user_id) | Credits per user | | `credits.exhausted.total` | Counter | Count of users hitting zero credits | | `scheduler.skipped.no_credits` | Counter | Items/sources/briefings skipped | | `ai.blocked.no_credits` | Counter | Safety net triggers | ### Logging Structured log fields for credit operations: ```json { "event": "credit_deducted", "userId": "uuid", "creditsDeducted": 1, "newBalance": 899, "newsItemId": "uuid" } ``` ### Alerts | Alert | Condition | Severity | |-------|-----------|----------| | Safety net triggered | `ai.blocked.no_credits` > 0 | ERROR (indicates bug) | | High credit exhaustion rate | `credits.exhausted.total` spike | WARN | ## Implementation Order 1. **Phase 1: Core Infrastructure** - Create `InsufficientCreditsException` - Extend `SubscriptionService` with new methods (hasCredits, getUserIdsWithCredits, useCredits overload) - Add new repository methods to `SubscriptionRepository` - Add handler to `GlobalExceptionHandler` 2. **Phase 2: Credit Deduction** - Modify `ProcessingWorker` to deduct credits - Add no-credits notification trigger 3. **Phase 3: Scheduler Blocking** - Modify `ProcessingSchedulerJob` - Modify `FeedSchedulerJob` - Modify `BriefingSchedulerJob` 4. **Phase 4: API and Listener Blocking** - Modify `DayBriefController.executeBriefing()` - Modify `EmailIngestionListener` 5. **Phase 5: Safety Net** - Modify `TrackedAiService` 6. **Phase 6: Email Reprocessing (Optional)** - Add `RawEmailStatus` enum and migration - Implement reprocessing job