Ctrl K
ComunidadGuías…

specs/012-credit-per-item/tasks.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Credit-Per-Item: Implementation Tasks

## Phase 1: Core Infrastructure

**Goal:** Create the exception type and extend SubscriptionService with credit-checking methods.

**Verification:** Unit tests pass for all new SubscriptionService methods.

### Task 1.1: Create InsufficientCreditsException

**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;

    // Constructor with all fields
    // Getters
}
```

### Task 1.2: Add Exception Handler

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/exception/GlobalExceptionHandler.java`

Add handler returning `HttpStatus.PAYMENT_REQUIRED` (402).

**Test:** `GlobalExceptionHandlerTest` - verify 402 response for `InsufficientCreditsException`.

### Task 1.3: Add Repository Methods

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/repository/SubscriptionRepository.java`

Add:
```java
@Query("SELECT s.creditsBalance FROM Subscription s WHERE s.user.id = :userId")
Optional<Integer> findCreditsBalanceByUserId(@Param("userId") UUID userId);

@Query("SELECT s.user.id FROM Subscription s WHERE s.creditsBalance > 0")
Set<UUID> findUserIdsWithCredits();
```

**Test:** `SubscriptionRepositoryTest` - verify queries return expected results.

### Task 1.4: Extend SubscriptionService

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/service/SubscriptionService.java`

Add methods:
```java
public boolean hasCredits(UUID userId)
public int getCreditsBalance(UUID userId)
public Set<UUID> getUserIdsWithCredits()
@Transactional
public boolean useCredits(UUID userId, int credits)  // Overload with notification trigger
```

The `useCredits(UUID, int)` overload:
1. Fetches user by ID
2. Delegates to existing `useCredits(User, int)`
3. After successful deduction, checks if balance hit zero
4. If zero, calls `emailService.sendNoCreditsEmail()`

**Tests:**
- `SubscriptionServiceTest.hasCredits_returnsTrue_whenBalancePositive`
- `SubscriptionServiceTest.hasCredits_returnsFalse_whenBalanceZero`
- `SubscriptionServiceTest.useCredits_sendsNotification_whenBalanceHitsZero`
- `SubscriptionServiceTest.getUserIdsWithCredits_returnsOnlyUsersWithCredits`

---

## Phase 2: Credit Deduction in ProcessingWorker

**Goal:** Deduct one credit per news item after successful AI enrichment.

**Verification:** Process a news item → verify credit decremented and `CreditUsageLog` entry created.

### Task 2.1: Inject SubscriptionService into ProcessingWorker

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/queue/ProcessingWorker.java`

Add constructor parameter for `SubscriptionService`.

### Task 2.2: Deduct Credit After AI Processing

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/queue/ProcessingWorker.java`

In `doProcess()` method, after successful `aiService.enrichWithScore()` call:

```java
UUID userId = item.getSource().getDayBrief().getUserId();

// After AI call succeeds
boolean deducted = subscriptionService.useCredits(userId, 1);
if (!deducted) {
    log.error("Failed to deduct credit after AI processing: userId={}, itemId={}", userId, item.getId());
    throw new InsufficientCreditsException(userId, 1, 0);
}
```

**Tests:**
- `ProcessingWorkerTest.process_deductsOneCredit_afterSuccessfulEnrichment`
- `ProcessingWorkerTest.process_createsUsageLog_afterSuccessfulEnrichment`
- `ProcessingWorkerTest.process_throwsException_whenCreditDeductionFails`

---

## Phase 3: Scheduler Blocking

**Goal:** Schedulers skip items/sources/briefings for users with zero credits.

**Verification:** Create user with zero credits → verify their items/sources/briefings are not scheduled.

### Task 3.1: Modify ProcessingSchedulerJob

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/job/ProcessingSchedulerJob.java`

Inject `SubscriptionService`. In `scheduleProcessing()`:

```java
Set<UUID> usersWithCredits = subscriptionService.getUserIdsWithCredits();
if (usersWithCredits.isEmpty()) {
    log.debug("No users with credits available");
    return;
}

// Filter candidates to only include items from users with credits
List<NewsItem> affordableItems = candidates.stream()
    .filter(item -> usersWithCredits.contains(item.getSource().getDayBrief().getUserId()))
    .toList();
```

**Tests:**
- `ProcessingSchedulerJobTest.scheduleProcessing_skipsItems_forUsersWithZeroCredits`
- `ProcessingSchedulerJobTest.scheduleProcessing_processesItems_forUsersWithCredits`

### Task 3.2: Modify FeedSchedulerJob

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/job/FeedSchedulerJob.java`

Inject `SubscriptionService`. Filter `dueForRefresh` stream:

```java
Set<UUID> usersWithCredits = subscriptionService.getUserIdsWithCredits();

List<Source> dueForRefresh = candidates.stream()
    .filter(s -> isDueForRefresh(s, now))
    .filter(s -> usersWithCredits.contains(s.getDayBrief().getUserId()))
    .limit(batchSize)
    .toList();
```

Log when sources are skipped due to zero credits.

**Tests:**
- `FeedSchedulerJobTest.scheduleFeeds_skipsSources_forUsersWithZeroCredits`
- `FeedSchedulerJobTest.scheduleFeeds_processesSources_forUsersWithCredits`

### Task 3.3: Modify BriefingSchedulerJob

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/job/BriefingSchedulerJob.java`

Inject `SubscriptionService`. Filter `dueBriefings` stream:

```java
Set<UUID> usersWithCredits = subscriptionService.getUserIdsWithCredits();

List<DayBrief> dueBriefings = candidateBriefings.stream()
    .filter(briefing -> isBriefingDue(briefing, nowUtc))
    .filter(briefing -> usersWithCredits.contains(briefing.getUserId()))
    .toList();
```

Log when briefings are skipped due to zero credits.

**Tests:**
- `BriefingSchedulerJobTest.scheduleBriefings_skipsBriefings_forUsersWithZeroCredits`
- `BriefingSchedulerJobTest.scheduleBriefings_processesBriefings_forUsersWithCredits`

---

## Phase 4: API and Listener Blocking

**Goal:** Block manual briefing execution and email extraction when zero credits.

**Verification:** Call manual execute endpoint with zero credits → receive 402. Send email to user with zero credits → email stored but not processed.

### Task 4.1: Block Manual Briefing Execution

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/controller/DayBriefController.java`

In `executeBriefing()` method, before `briefingWorker.executeManually()`:

```java
if (!subscriptionService.hasCredits(user.getId())) {
    throw new InsufficientCreditsException(user.getId(), 1, 0);
}
```

**Tests:**
- `DayBriefControllerTest.executeBriefing_returns402_whenZeroCredits`
- `DayBriefControllerTest.executeBriefing_succeeds_whenCreditsAvailable`

### Task 4.2: Block Email Extraction

**File:** `backend/src/main/java/be/transcode/morningdeck/server/core/service/EmailIngestionListener.java`

Before `aiService.extractFromEmail()`:

```java
UUID userId = source.getDayBrief().getUserId();
if (!subscriptionService.hasCredits(userId)) {
    log.warn("Email received but user {} has no credits. Stored but not processed: messageId={}",
            userId, email.getMessageId());
    // RawEmail already saved earlier - just don't process
    return;
}
```

**Tests:**
- `EmailIngestionListenerTest.handleEmailReceived_skipsProcessing_whenZeroCredits`
- `EmailIngestionListenerTest.handleEmailReceived_storesRawEmail_evenWhenZeroCredits`
- `EmailIngestionListenerTest.handleEmailReceived_processesEmail_whenCreditsAvailable`

---

## Phase 5: Safety Net in TrackedAiService

**Goal:** Defense-in-depth: throw exception if AI call attempted with zero credits.

**Verification:** Trigger safety net → ERROR log and exception thrown.

### Task 5.1: Add Credit Check in TrackedAiService

**File:** `backend/src/main/java/be/transcode/morningdeck/server/provider/ai/TrackedAiService.java`

Inject `SubscriptionService`. At start of `trackCall()`:

```java
UUID userId = AiUsageContext.getUserId();
if (userId != null) {
    int balance = subscriptionService.getCreditsBalance(userId);
    if (balance <= 0) {
        log.error("SAFETY NET: AI call attempted with zero credits: userId={} feature={}", userId, feature);
        throw new InsufficientCreditsException(userId, 1, balance);
    }
}
```

**Tests:**
- `TrackedAiServiceTest.trackCall_throwsException_whenZeroCredits`
- `TrackedAiServiceTest.trackCall_proceeds_whenCreditsAvailable`
- `TrackedAiServiceTest.trackCall_proceeds_whenNoUserContext` (system/admin operations)

---

## Phase 6: Integration Tests

**Goal:** End-to-end verification of credit enforcement.

**Verification:** All integration tests pass.

### Task 6.1: Credit Deduction Integration Test

**File:** `backend/src/test/java/be/transcode/morningdeck/server/core/CreditEnforcementIT.java`

Test scenarios:
- Create user with 5 credits
- Process 5 news items
- Verify credit balance is 0
- Verify 5 `CreditUsageLog` entries
- Verify no-credits notification email sent

### Task 6.2: Scheduler Credit Filtering Integration Test

**File:** Same as 6.1

Test scenarios:
- Create two users: one with credits, one without
- Create news items for both users
- Run ProcessingSchedulerJob
- Verify only items for user with credits are scheduled

### Task 6.3: API Blocking Integration Test

**File:** Same as 6.1

Test scenarios:
- Create user with zero credits
- Call `POST /daybriefs/{id}/execute`
- Verify 402 Payment Required response

---

## Phase 7: Documentation Update

**Goal:** Document credit system behavior.

**Verification:** Documentation reflects implemented behavior.

### Task 7.1: Update Documentation

**File:** `documentation/credits.md` (create if not exists)

Document:
- Credit costs per operation (1 credit per processed item)
- What happens when credits reach zero
- How credits are replenished (monthly auto-renewal)
- No-credits notification behavior

### Task 7.2: Update API Documentation

Ensure OpenAPI/Swagger reflects 402 responses on relevant endpoints.