feat(marketplace): purchase repository — build/complete/refund docs, 70/30 revenue share, author earnings aggregation (15 tests)

This commit is contained in:
saravanakumardb1 2026-03-01 16:39:38 -08:00
parent 063efa8e41
commit d0cb3a2238
11 changed files with 823 additions and 6 deletions

View File

@ -1,9 +1,9 @@
Last refresh: 2026-03-01T07:00:13Z (2026-02-28 23:00:13 PST)
Last refresh: 2026-03-02T00:28:03Z (2026-03-01 16:28:03 PST)
Cascade conversations: 50 (495M)
Memories: 56
Memories: 59
Implicit context: 20
Code tracker dirs: 182
File edit history: 2010 entries
Code tracker dirs: 230
File edit history: 2075 entries
Workspace storage: 28 workspaces
Repo docs: 14 files across 3 repos
Repo workflows: 28 files across 5 repos
Repo docs: 15 files across 3 repos
Repo workflows: 35 files across 6 repos

View File

@ -0,0 +1,186 @@
# Azure Connection Audit — Full Workspace Report
> **Date:** 2026-02-22
> **Scope:** `learning_ai_common_plat`, `learning_voice_ai_agent`, `learning_multimodal_memory_agents`, `learning_ai_clock`, `learning_ai_fastgap`
> **Auditor:** Cascade (AI)
---
## Executive Summary
| Category | Issues Found | Fixed (session 1) | Fixed (session 2) | Remaining |
| ---------------------- | ------------ | ----------------- | ----------------------------------------- | ------------------- |
| `x-request-id` missing | 12 clients | 2 (MindLyst) | **9** (root cause + feature-flags) | 0 ✅ |
| `x-product-id` missing | 6 clients | 0 | **6** (admin + user dashboards + Python) | 0 ✅ |
| Cosmos PK mismatch | 1 container | 0 (flagged) | 0 | 1 (needs migration) |
| `.env.example` gaps | 4 files | 1 (MindLyst) | **3** (ChronoMind, user-dash, admin-dash) | 0 ✅ |
| Hardcoded productId | 2 instances | 0 | **2** (telemetry.ts, platform_client.py) | 0 ✅ |
| Python client gaps | 1 file | 0 | **1** (headers + config) | 0 ✅ |
---
## 1. `x-request-id` Header — Root Cause
### Finding
**`@bytelyst/api-client` does NOT auto-inject `x-request-id`.**
The `createApiClient()` factory in `packages/api-client/src/client.ts` only sets `Content-Type`, auth token (via `getToken`), and caller-supplied `defaultHeaders`. No `x-request-id` is generated. This means **every consumer** that relies on `@bytelyst/api-client` without explicitly adding the header is missing request tracing.
### Root Cause Fix
Add `x-request-id: crypto.randomUUID()` to `buildHeaders()` in `packages/api-client/src/client.ts`. This single change propagates to all consumers automatically.
### Affected Clients (missing `x-request-id`)
| Repo | File | Client Pattern |
| ---------------- | -------------------------------------------------- | ------------------------------------- |
| `common_plat` | `dashboards/admin-web/src/lib/billing-client.ts` | `createApiClient` — no `x-request-id` |
| `common_plat` | `dashboards/admin-web/src/lib/growth-client.ts` | `createApiClient` — no `x-request-id` |
| `common_plat` | `dashboards/admin-web/src/lib/platform-client.ts` | `createApiClient` — no `x-request-id` |
| `common_plat` | `dashboards/tracker-web/src/lib/tracker-client.ts` | `createApiClient` — no `x-request-id` |
| `common_plat` | `packages/extraction/src/client.ts` | `createApiClient` — no `x-request-id` |
| `voice_ai_agent` | `user-dashboard-web/src/lib/billing-client.ts` | `createApiClient` — no `x-request-id` |
| `voice_ai_agent` | `user-dashboard-web/src/lib/growth-client.ts` | `createApiClient` — no `x-request-id` |
| `voice_ai_agent` | `user-dashboard-web/src/lib/platform-client.ts` | `createApiClient` — no `x-request-id` |
| `voice_ai_agent` | `user-dashboard-web/src/lib/feature-flags.ts` | Custom `fetch` — no `x-request-id` |
| `voice_ai_agent` | `backend/src/clients/platform_client.py` | `httpx` — no `x-request-id` |
### Already Fixed (previous session)
| Repo | File | Status |
| ------------------- | ------------------------------- | ----------------------------- |
| `multimodal_memory` | `web/src/lib/billing-client.ts` | ✅ Added via `defaultHeaders` |
| `multimodal_memory` | `web/src/lib/feature-flags.ts` | ✅ Added manually |
### Already Correct
| Repo | File | Status |
| ----------------------- | ------------------------------------------ | ------------------------------------------- |
| `ai_fastgap` (NomGap) | `src/api/client.ts` | ✅ Custom client with `crypto.randomUUID()` |
| `ai_clock` (ChronoMind) | `web/src/lib/platform-sync.ts` | ✅ Custom client with `crypto.randomUUID()` |
| `voice_ai_agent` | `backend/src/main.py` | ✅ Middleware propagates/generates |
| `voice_ai_agent` | `backend/src/clients/extraction_client.py` | ✅ Passes `request_id` param |
---
## 2. `x-product-id` Header Gaps
### Clients Missing `x-product-id`
| Repo | File | Impact |
| ---------------- | ----------------------------------------------- | --------------------------------- |
| `common_plat` | `admin-web/src/lib/billing-client.ts` | Server can't filter by product |
| `common_plat` | `admin-web/src/lib/growth-client.ts` | Server can't filter by product |
| `voice_ai_agent` | `user-dashboard-web/src/lib/billing-client.ts` | Server can't filter by product |
| `voice_ai_agent` | `user-dashboard-web/src/lib/growth-client.ts` | Server can't filter by product |
| `voice_ai_agent` | `user-dashboard-web/src/lib/platform-client.ts` | Passes in body, not header |
| `voice_ai_agent` | `backend/src/clients/platform_client.py` | Passes in body/params, not header |
### Already Correct
| Repo | File |
| ------------------------------ | ------------------------------------------------------------- |
| `ai_fastgap` (NomGap) | `src/api/client.ts``x-product-id: API_CONFIG.productId` |
| `ai_clock` (ChronoMind) | `web/src/lib/platform-sync.ts``x-product-id` header |
| `multimodal_memory` (MindLyst) | `web/src/lib/billing-client.ts` — via `defaultHeaders` |
| `multimodal_memory` (MindLyst) | `web/src/lib/feature-flags.ts` — explicit header |
| `common_plat` | `tracker-web/src/lib/tracker-client.ts` — from `localStorage` |
---
## 3. Cosmos DB Partition Key Mismatch
### `referrals` Container — 3-way Mismatch
| Location | Partition Key |
| ----------------------------------------------------- | ------------- |
| `platform-service/src/lib/cosmos-init.ts` | `/id` |
| MindLyst `web/src/lib/cosmos.ts` | `/userId` |
| Admin dashboard `admin-web/src/lib/cosmos.ts` | `/referrerId` |
| User dashboard `user-dashboard-web/src/lib/cosmos.ts` | `/referrerId` |
**Status:** Flagged in previous session. Cannot be fixed without data migration. Comment added to `cosmos-init.ts`.
**Risk:** Cross-partition queries will silently succeed but may return incomplete results or fail on point reads if the wrong partition key is specified.
---
## 4. Missing Environment Variables in `.env.example` Files
### ChronoMind `web/.env.example`
Currently only has:
```
NEXT_PUBLIC_PLATFORM_SERVICE_URL=http://localhost:4003/api
```
**Missing:**
- `NEXT_PUBLIC_PRODUCT_ID=chronomind` — used implicitly by `platform-sync.ts` (hardcoded there, but should be env-driven for consistency)
### LysnrAI `user-dashboard-web/.env.example`
**Missing:**
- `NEXT_PUBLIC_PRODUCT_ID=lysnrai` — referenced by `feature-flags.ts` line 10
- `NEXT_PUBLIC_PLATFORM_SERVICE_URL=http://localhost:4003` — referenced by `feature-flags.ts` line 11
Has `PLATFORM_SERVICE_URL` (server-side) but not the `NEXT_PUBLIC_` variant (client-side).
### LysnrAI root `.env.example`
**Missing:**
- `NEXT_PUBLIC_PRODUCT_ID` — not needed at root level (desktop app), so this is informational only.
### Admin dashboard `.env.example`
**Missing:**
- `AZURE_KEYVAULT_URL` — referenced by `instrumentation.ts` but not in `.env.example`
---
## 5. Hardcoded `productId` Values
| Repo | File | Line | Value | Should Use |
| ------------------- | ---------------------------------------- | ------- | ----------------------------- | ------------------------------------ |
| `multimodal_memory` | `web/src/lib/telemetry.ts` | 19 | `productId: 'mindlyst'` | `process.env.NEXT_PUBLIC_PRODUCT_ID` |
| `voice_ai_agent` | `backend/src/clients/platform_client.py` | 86, 101 | `product_id: str = "lysnrai"` | `settings.PRODUCT_ID` or config |
---
## 6. Python Backend Client Gaps (`platform_client.py`)
The `PlatformClient` class in `backend/src/clients/platform_client.py` has several issues:
1. **No `x-request-id` header** on any request
2. **No `x-product-id` header** on any request
3. **Creates new `httpx.AsyncClient` per request** — no connection pooling
4. **Hardcoded `product_id="lysnrai"` defaults** — should use config
---
## 7. Previously Fixed (Session 1)
| Fix | Repo | File |
| ------------------------------------------- | ------------------- | -------------------------------------------------- |
| Added `x-request-id` to billing client | `multimodal_memory` | `web/src/lib/billing-client.ts` |
| Added `x-request-id` to feature flags | `multimodal_memory` | `web/src/lib/feature-flags.ts` |
| Added 13 MindLyst containers to cosmos-init | `common_plat` | `services/platform-service/src/lib/cosmos-init.ts` |
| Added Blob Storage creds to Python config | `voice_ai_agent` | `backend/src/config.py` |
| Added missing env vars to MindLyst | `multimodal_memory` | `web/.env.example` |
---
## 8. Recommended Fix Order
1. **P0 — Root cause:** Add `x-request-id` auto-generation to `@bytelyst/api-client` `buildHeaders()` → fixes 9 TS clients at once
2. **P0 — LysnrAI feature-flags:** Add `x-request-id` to the custom `fetch` call in `user-dashboard-web/src/lib/feature-flags.ts`
3. **P1 — Python backend:** Add `x-request-id` and `x-product-id` headers to `platform_client.py`
4. **P1 — Env vars:** Add missing `NEXT_PUBLIC_*` vars to ChronoMind, LysnrAI user-dashboard, admin-dashboard `.env.example` files
5. **P2 — `x-product-id`:** Add to admin/user dashboard clients via `defaultHeaders` in `createApiClient` config
6. **P2 — Hardcoded productId:** Replace in `telemetry.ts` and `platform_client.py`
7. **P3 — Referrals PK mismatch:** Requires data migration strategy (separate task)

View File

@ -0,0 +1,74 @@
---
description: Verify iOS/mobile code compiles, all files are in Xcode targets, and passes quality checks
---
# Mobile Code Quality — PeakPulse
## Steps
1. Ensure working tree is clean:
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git status --short
```
2. Generate Xcode project from project.yml:
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse/ios && xcodegen generate
```
3. Build PeakPulse target (iOS Simulator):
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse/ios && xcodebuild -project PeakPulse.xcodeproj -scheme PeakPulse -destination 'platform=iOS Simulator,name=iPhone 16' -quiet build
```
4. Build PeakPulseTests target:
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse/ios && xcodebuild -project PeakPulse.xcodeproj -scheme PeakPulse -destination 'platform=iOS Simulator,name=iPhone 16' -quiet test
```
5. Check for print() statements (should use os.Logger instead):
// turbo
```bash
grep -rn "print(" /Users/sd9235/code/mygh/learning_ai_peakpulse/ios/PeakPulse/ --include="*.swift" | grep -v "// print" | grep -v "/// " || echo "No print() found — OK"
```
6. Check for force unwraps:
// turbo
```bash
grep -rn '![^=]' /Users/sd9235/code/mygh/learning_ai_peakpulse/ios/PeakPulse/ --include="*.swift" | grep -v 'IBOutlet' | grep -v '// !' | grep -v '!=' | grep -v '!//' | head -20 || echo "No force unwraps found — OK"
```
7. Verify all Swift files are under ios/:
// turbo
```bash
find /Users/sd9235/code/mygh/learning_ai_peakpulse/ios -name "*.swift" | wc -l
```
8. Check entitlements file has required keys:
// turbo
```bash
cat /Users/sd9235/code/mygh/learning_ai_peakpulse/ios/PeakPulse/PeakPulse.entitlements
```
## Key Files
- `ios/project.yml` — XcodeGen project spec
- `ios/PeakPulse/PeakPulse.entitlements` — App entitlements
- `ios/PeakPulse/Theme/PeakPulseTheme.swift` — Design tokens
## Troubleshooting
| Issue | Fix |
| ----------------------------- | -------------------------------------------------------------------------- |
| ByteLystPlatformSDK not found | Ensure `../../learning_ai_common_plat/packages/swift-platform-sdk/` exists |
| xcodegen not installed | `brew install xcodegen` |
| Build fails on Simulator | Check deployment target is iOS 17.0 |

View File

@ -0,0 +1,82 @@
---
description: Production readiness check — run all checks, fix as you go, commit incrementally
---
# Production Readiness — PeakPulse
## Steps
1. Check git status is clean:
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git status --short
```
2. Verify project.yml generates successfully:
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse/ios && xcodegen generate
```
3. Build all iOS targets:
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse/ios && xcodebuild -project PeakPulse.xcodeproj -scheme PeakPulse -destination 'platform=iOS Simulator,name=iPhone 16' -quiet build
```
4. Run unit tests:
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse/ios && xcodebuild -project PeakPulse.xcodeproj -scheme PeakPulse -destination 'platform=iOS Simulator,name=iPhone 16' -quiet test
```
5. Verify no print() statements in production code:
// turbo
```bash
grep -rn "print(" /Users/sd9235/code/mygh/learning_ai_peakpulse/ios/PeakPulse/ --include="*.swift" | grep -v "// " || echo "PASS: No print() found"
```
6. Verify no hardcoded colors (should use PeakPulseColors.\*):
// turbo
```bash
grep -rn "Color(" /Users/sd9235/code/mygh/learning_ai_peakpulse/ios/PeakPulse/Views/ --include="*.swift" | grep -v "PeakPulseColors" | grep -v Theme | head -10 || echo "PASS: No hardcoded colors"
```
7. Verify entitlements are correct:
// turbo
```bash
cat /Users/sd9235/code/mygh/learning_ai_peakpulse/ios/PeakPulse/PeakPulse.entitlements
```
8. Verify .env.example has all required keys:
// turbo
```bash
cat /Users/sd9235/code/mygh/learning_ai_peakpulse/.env.example
```
9. Run platform-service peak-sessions tests:
```bash
cd /Users/sd9235/code/mygh/learning_ai_common_plat && npx vitest run services/platform-service/src/modules/peak-sessions/peak-sessions.test.ts
```
10. Verify CI workflow exists and is valid:
// turbo
```bash
cat /Users/sd9235/code/mygh/learning_ai_peakpulse/.github/workflows/ci.yml | head -20
```
11. Check file counts match docs:
// turbo
```bash
echo "Swift files:" && find /Users/sd9235/code/mygh/learning_ai_peakpulse/ios -name "*.swift" | wc -l && echo "Test files:" && find /Users/sd9235/code/mygh/learning_ai_peakpulse/ios/PeakPulseTests -name "*.swift" | wc -l
```
12. Final commit and push if any fixes were made.

View File

@ -0,0 +1,36 @@
---
description: Backup main branch then push PeakPulse repo to origin
---
1. Check for uncommitted changes
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git status --short
```
2. If there are changes, commit them with an appropriate message
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git add -A && git commit -m "chore: save work in progress"
```
3. Create a timestamped backup branch
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git branch backup/main-$(date +%Y%m%d-%H%M%S)
```
4. Push main to origin
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git push origin main
```
5. Verify push succeeded
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git log --oneline -3
```

View File

@ -0,0 +1,31 @@
---
description: Smart backup of main branches with duplicate detection
---
1. List existing backup branches
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git branch --list 'backup/*' | tail -5
```
2. Get current HEAD commit
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git rev-parse --short HEAD
```
3. Create backup branch with timestamp (skip if HEAD hasn't changed since last backup)
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git branch backup/main-$(date +%Y%m%d-%H%M%S)
```
4. Confirm backup created
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git branch --list 'backup/*' | tail -3
```

View File

@ -0,0 +1,44 @@
---
description: Commit all workspace changes in logical order with intelligent messages
---
# Commit Workspace Changes — PeakPulse
## Steps
1. Check for uncommitted changes:
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git status --short
```
2. If there are changes, stage all files:
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git add -A
```
3. Review staged changes to craft an intelligent commit message:
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git diff --cached --stat
```
4. Commit with a descriptive single-line message following the convention `type(scope): description`:
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git commit -m "<type>(<scope>): <description>"
```
**Commit types:** feat, fix, docs, refactor, test, chore
**Common scopes:** models, services, views, components, platform, tests, config, docs
5. Verify commit:
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git log -1 --oneline
```

View File

@ -0,0 +1,30 @@
---
description: Push local main branch to origin for PeakPulse repo
---
1. Check current branch is main
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git branch --show-current
```
2. Check for uncommitted changes
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git status --short
```
3. Push main to origin
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git push origin main
```
4. Verify
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git log --oneline -1
```

View File

@ -0,0 +1,27 @@
---
description: Pull latest from origin main for PeakPulse repo
---
# Sync Repos — PeakPulse
## Steps
1. Ensure working tree is clean:
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git status --short
```
2. Pull latest from origin main:
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git pull origin main
```
3. Verify current state:
// turbo
```bash
cd /Users/sd9235/code/mygh/learning_ai_peakpulse && git log -3 --oneline
```

View File

@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import {
buildPurchaseDoc,
completePurchase,
refundPurchase,
aggregateAuthorEarnings,
type PurchaseDoc,
} from './purchase-repository.js';
describe('buildPurchaseDoc', () => {
const base = {
productId: 'jarvisjr',
userId: 'user_1',
listingId: 'listing_1',
listingTitle: 'Interview Coach Pro',
authorId: 'author_1',
priceUsd: 4.99,
};
it('creates a purchase with unique id', () => {
const a = buildPurchaseDoc(base);
const b = buildPurchaseDoc(base);
expect(a.id).not.toBe(b.id);
expect(a.id).toMatch(/^purchase_/);
});
it('sets status to pending', () => {
const doc = buildPurchaseDoc(base);
expect(doc.status).toBe('pending');
});
it('calculates 70/30 revenue share', () => {
const doc = buildPurchaseDoc(base);
expect(doc.authorShareUsd).toBeCloseTo(3.49, 2);
expect(doc.platformShareUsd).toBeCloseTo(1.5, 2);
});
it('revenue shares sum to price', () => {
const doc = buildPurchaseDoc(base);
expect(doc.authorShareUsd + doc.platformShareUsd).toBeCloseTo(base.priceUsd, 1);
});
it('stores stripe session id if provided', () => {
const doc = buildPurchaseDoc({ ...base, stripeSessionId: 'cs_test_123' });
expect(doc.stripeSessionId).toBe('cs_test_123');
});
it('defaults stripe session to null', () => {
const doc = buildPurchaseDoc(base);
expect(doc.stripeSessionId).toBeNull();
});
it('sets completedAt and refundedAt to null', () => {
const doc = buildPurchaseDoc(base);
expect(doc.completedAt).toBeNull();
expect(doc.refundedAt).toBeNull();
});
});
describe('completePurchase', () => {
it('marks purchase as completed', () => {
const doc = buildPurchaseDoc({
productId: 'jarvisjr',
userId: 'u1',
listingId: 'l1',
listingTitle: 'Test',
authorId: 'a1',
priceUsd: 9.99,
});
const completed = completePurchase(doc, 'pi_test_456');
expect(completed.status).toBe('completed');
expect(completed.stripePaymentIntentId).toBe('pi_test_456');
expect(completed.completedAt).toBeTruthy();
});
it('preserves original fields', () => {
const doc = buildPurchaseDoc({
productId: 'jarvisjr',
userId: 'u1',
listingId: 'l1',
listingTitle: 'Test',
authorId: 'a1',
priceUsd: 4.99,
});
const completed = completePurchase(doc, 'pi_test');
expect(completed.id).toBe(doc.id);
expect(completed.priceUsd).toBe(doc.priceUsd);
expect(completed.authorShareUsd).toBe(doc.authorShareUsd);
});
});
describe('refundPurchase', () => {
it('marks purchase as refunded with reason', () => {
const doc = buildPurchaseDoc({
productId: 'jarvisjr',
userId: 'u1',
listingId: 'l1',
listingTitle: 'Test',
authorId: 'a1',
priceUsd: 4.99,
});
const completed = completePurchase(doc, 'pi_test');
const refunded = refundPurchase(completed, 'Customer requested refund');
expect(refunded.status).toBe('refunded');
expect(refunded.refundReason).toBe('Customer requested refund');
expect(refunded.refundedAt).toBeTruthy();
});
});
describe('aggregateAuthorEarnings', () => {
function makePurchase(overrides: Partial<PurchaseDoc> = {}): PurchaseDoc {
return {
id: `purchase_${Math.random()}`,
productId: 'jarvisjr',
userId: 'user_1',
listingId: 'listing_1',
listingTitle: 'Interview Coach',
authorId: 'author_1',
priceUsd: 4.99,
authorShareUsd: 3.49,
platformShareUsd: 1.5,
stripeSessionId: null,
stripePaymentIntentId: 'pi_test',
status: 'completed',
refundReason: null,
createdAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
refundedAt: null,
...overrides,
};
}
it('returns zero for no purchases', () => {
const result = aggregateAuthorEarnings([], 'author_1');
expect(result.totalEarningsUsd).toBe(0);
expect(result.totalSales).toBe(0);
expect(result.listings).toHaveLength(0);
});
it('aggregates single listing sales', () => {
const purchases = [makePurchase(), makePurchase(), makePurchase()];
const result = aggregateAuthorEarnings(purchases, 'author_1');
expect(result.totalSales).toBe(3);
expect(result.totalEarningsUsd).toBeCloseTo(10.47, 2);
expect(result.listings).toHaveLength(1);
expect(result.listings[0].sales).toBe(3);
});
it('aggregates multiple listing sales', () => {
const purchases = [
makePurchase({ listingId: 'listing_1', listingTitle: 'Coach A', authorShareUsd: 3.49 }),
makePurchase({ listingId: 'listing_2', listingTitle: 'Coach B', authorShareUsd: 6.99 }),
makePurchase({ listingId: 'listing_1', listingTitle: 'Coach A', authorShareUsd: 3.49 }),
];
const result = aggregateAuthorEarnings(purchases, 'author_1');
expect(result.totalSales).toBe(3);
expect(result.listings).toHaveLength(2);
});
it('excludes refunded purchases', () => {
const purchases = [makePurchase(), makePurchase({ status: 'refunded' })];
const result = aggregateAuthorEarnings(purchases, 'author_1');
expect(result.totalSales).toBe(1);
});
it('excludes other authors', () => {
const purchases = [
makePurchase({ authorId: 'author_1' }),
makePurchase({ authorId: 'author_2' }),
];
const result = aggregateAuthorEarnings(purchases, 'author_1');
expect(result.totalSales).toBe(1);
});
});

View File

@ -0,0 +1,133 @@
/**
* Purchase Repository manages marketplace purchase records.
* Tracks who bought what, revenue share, and refund status.
*/
import crypto from 'node:crypto';
export interface PurchaseDoc {
id: string;
productId: string;
userId: string;
listingId: string;
listingTitle: string;
authorId: string;
priceUsd: number;
authorShareUsd: number;
platformShareUsd: number;
stripeSessionId: string | null;
stripePaymentIntentId: string | null;
status: 'pending' | 'completed' | 'refunded' | 'failed';
refundReason: string | null;
createdAt: string;
completedAt: string | null;
refundedAt: string | null;
}
// Revenue share: 70% author, 30% platform
const AUTHOR_SHARE = 0.7;
const PLATFORM_SHARE = 0.3;
export function buildPurchaseDoc(input: {
productId: string;
userId: string;
listingId: string;
listingTitle: string;
authorId: string;
priceUsd: number;
stripeSessionId?: string;
}): PurchaseDoc {
const now = new Date().toISOString();
const authorShare = Math.round(input.priceUsd * AUTHOR_SHARE * 100) / 100;
const platformShare = Math.round(input.priceUsd * PLATFORM_SHARE * 100) / 100;
return {
id: `purchase_${crypto.randomUUID()}`,
productId: input.productId,
userId: input.userId,
listingId: input.listingId,
listingTitle: input.listingTitle,
authorId: input.authorId,
priceUsd: input.priceUsd,
authorShareUsd: authorShare,
platformShareUsd: platformShare,
stripeSessionId: input.stripeSessionId ?? null,
stripePaymentIntentId: null,
status: 'pending',
refundReason: null,
createdAt: now,
completedAt: null,
refundedAt: null,
};
}
export function completePurchase(doc: PurchaseDoc, paymentIntentId: string): PurchaseDoc {
return {
...doc,
status: 'completed',
stripePaymentIntentId: paymentIntentId,
completedAt: new Date().toISOString(),
};
}
export function refundPurchase(doc: PurchaseDoc, reason: string): PurchaseDoc {
return {
...doc,
status: 'refunded',
refundReason: reason,
refundedAt: new Date().toISOString(),
};
}
// ── Author Earnings Aggregation ─────────────────────────────
export interface AuthorEarnings {
authorId: string;
totalEarningsUsd: number;
totalSales: number;
pendingPayoutUsd: number;
listings: Array<{
listingId: string;
listingTitle: string;
sales: number;
earningsUsd: number;
}>;
}
export function aggregateAuthorEarnings(
purchases: PurchaseDoc[],
authorId: string
): AuthorEarnings {
const completed = purchases.filter(p => p.authorId === authorId && p.status === 'completed');
const byListing = new Map<string, { title: string; sales: number; earnings: number }>();
let totalEarnings = 0;
for (const p of completed) {
totalEarnings += p.authorShareUsd;
const existing = byListing.get(p.listingId);
if (existing) {
existing.sales += 1;
existing.earnings += p.authorShareUsd;
} else {
byListing.set(p.listingId, {
title: p.listingTitle,
sales: 1,
earnings: p.authorShareUsd,
});
}
}
return {
authorId,
totalEarningsUsd: Math.round(totalEarnings * 100) / 100,
totalSales: completed.length,
pendingPayoutUsd: Math.round(totalEarnings * 100) / 100, // Stub: all earnings are pending
listings: Array.from(byListing.entries()).map(([listingId, data]) => ({
listingId,
listingTitle: data.title,
sales: data.sales,
earningsUsd: Math.round(data.earnings * 100) / 100,
})),
};
}