feat(cards): Phase ζ.1 — Paid decks via mana-credits

Server (cards-server):
- lib/credits.ts: thin internal-API client for mana-credits
  (reserve / commit / refund-reservation / grant). Service-to-
  service via X-Service-Key. Throws InsufficientCreditsError
  separately so the buy flow can branch on UX.
- services/purchases.ts: 4-step purchase pipeline: reserve →
  insert deck_purchases row → commit reservation → grant
  author share + insert author_payouts. Idempotent on
  (buyer, deck) so a refresh-spam-click can't double-charge.
  Verified-mana authors get the 90/10 split, others 80/20
  (already in config). Refunds intentionally out of scope —
  see MARKETPLACE_PLAN §13a.
- routes/purchases.ts: POST /v1/decks/:slug/purchase,
  GET /v1/me/purchases, GET /v1/authors/me/payouts.
- decks.bySlug now returns hasPurchased (null when anonymous,
  bool when authed) so the deck-detail page can pick the right
  CTA.
- subscriptions.subscribe now blocks paid decks unless the
  caller has a non-refunded purchase row (owner exempt for
  testing).
- Notify: author gets a "Verkauf"-Email at grant time, with a
  deterministic externalId for dedup.

Frontend (cards-web):
- /d/<slug> shows "Kaufen für N 💎" instead of "Abonnieren"
  when paid + not yet bought; flips to subscribe path once
  purchased.
- /me/purchases page listing buyer history + (when present)
  author-payout history. Linked from the top nav.
This commit is contained in:
Till JS 2026-05-07 23:10:18 +02:00
parent 4fcc15737f
commit 5dbc9ace2d
10 changed files with 627 additions and 12 deletions

View file

@ -133,10 +133,11 @@ export const cardsApi = {
priceCredits?: number;
}) => request<PublicDeck>('/v1/decks', { method: 'POST', body: input }),
bySlug: (slug: string) =>
request<{ deck: PublicDeck; latestVersion: PublicDeckVersion | null }>(
`/v1/decks/${encodeURIComponent(slug)}`,
{ auth: 'optional' }
),
request<{
deck: PublicDeck;
latestVersion: PublicDeckVersion | null;
hasPurchased: boolean | null;
}>(`/v1/decks/${encodeURIComponent(slug)}`, { auth: 'optional' }),
publish: (
slug: string,
input: {
@ -240,6 +241,17 @@ export const cardsApi = {
reject: (id: string) =>
request<{ ok: true }>(`/v1/pull-requests/${id}/reject`, { method: 'POST' }),
},
purchases: {
buy: (deckSlug: string) =>
request<PurchaseResult>(`/v1/decks/${encodeURIComponent(deckSlug)}/purchase`, {
method: 'POST',
body: {},
}),
listMine: () => request<BuyerPurchase[]>('/v1/me/purchases'),
},
payouts: {
listMine: () => request<AuthorPayout[]>('/v1/authors/me/payouts'),
},
discussions: {
countsForDeck: (deckSlug: string) =>
request<Record<string, number>>(
@ -386,6 +398,49 @@ export interface PullRequest {
resolvedAt: string | null;
}
export interface PurchaseResult {
purchase: {
id: string;
buyerUserId: string;
deckId: string;
versionId: string;
priceCredits: number;
authorShare: number;
manaShare: number;
purchasedAt: string;
refundedAt: string | null;
};
payout: {
id: string;
authorUserId: string;
creditsGranted: number;
grantedAt: string;
} | null;
alreadyOwned: boolean;
}
export interface BuyerPurchase {
id: string;
deckId: string;
deckSlug: string;
deckTitle: string;
priceCredits: number;
purchasedAt: string;
refundedAt: string | null;
versionId: string;
versionSemver: string;
}
export interface AuthorPayout {
id: string;
purchaseId: string;
creditsGranted: number;
grantedAt: string;
deckSlug: string;
deckTitle: string;
priceCredits: number;
}
export interface CardDiscussion {
id: string;
cardContentHash: string;

View file

@ -54,6 +54,7 @@
<nav class="flex items-center gap-4 text-xs text-neutral-400">
<a href="/" class="hover:text-neutral-100">Meine Decks</a>
<a href="/explore" class="hover:text-neutral-100">Entdecken</a>
<a href="/me/purchases" class="hover:text-neutral-100">Käufe</a>
</nav>
<div class="flex items-center gap-3 text-xs text-neutral-500">
{#if streak > 0}

View file

@ -23,8 +23,14 @@
let subscribed = $state(false);
let subscribeBusy = $state(false);
let subscribedDeckId = $state<string | null>(null);
let hasPurchased = $state<boolean | null>(null);
let purchaseBusy = $state(false);
let error = $state<string | null>(null);
const isPaid = $derived(!!deck && deck.priceCredits > 0);
const canSubscribeNow = $derived(!isPaid || hasPurchased === true);
const isOwner = $derived(!!deck && authStore.user?.id === deck.ownerUserId);
$effect(() => {
if (!slug) return;
load();
@ -36,6 +42,7 @@
const r = await cardsApi.decks.bySlug(slug);
deck = r.deck;
version = r.latestVersion;
hasPurchased = r.hasPurchased;
subscribed = await isSubscribedLocally(slug);
if (subscribed) {
const local = await cardDeckTable
@ -75,6 +82,21 @@
}
}
async function buy() {
if (!deck || purchaseBusy) return;
if (!confirm(`Deck „${deck.title}" für ${deck.priceCredits} Credits kaufen?`)) return;
purchaseBusy = true;
error = null;
try {
await cardsApi.purchases.buy(deck.slug);
hasPurchased = true;
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
purchaseBusy = false;
}
}
async function toggleSubscribe() {
if (!deck || subscribeBusy) return;
subscribeBusy = true;
@ -177,6 +199,14 @@
Lernen
</button>
{/if}
{:else if isPaid && !canSubscribeNow && !isOwner}
<button
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-amber-950 hover:bg-amber-400 disabled:opacity-50"
onclick={buy}
disabled={purchaseBusy || !version}
>
{purchaseBusy ? 'Verarbeite…' : `Kaufen für ${deck.priceCredits} 💎`}
</button>
{:else}
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400 disabled:opacity-50"
@ -186,6 +216,14 @@
>
{subscribeBusy ? 'Abonniere…' : 'Abonnieren'}
</button>
{#if isPaid && hasPurchased}
<span
class="rounded-full bg-emerald-500/15 px-2 py-1 text-xs text-emerald-300"
title="Du besitzt dieses Deck"
>
✓ Gekauft
</span>
{/if}
{/if}
{:else}
<a

View file

@ -0,0 +1,132 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
cardsApi,
CardsApiError,
type BuyerPurchase,
type AuthorPayout,
} from '$lib/api/cards-api';
let purchases = $state<BuyerPurchase[]>([]);
let payouts = $state<AuthorPayout[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
const totalSpent = $derived(
purchases.filter((p) => !p.refundedAt).reduce((acc, p) => acc + p.priceCredits, 0)
);
const totalEarned = $derived(payouts.reduce((acc, p) => acc + p.creditsGranted, 0));
onMount(async () => {
try {
const [p, py] = await Promise.all([
cardsApi.purchases.listMine(),
cardsApi.payouts.listMine().catch(() => [] as AuthorPayout[]),
]);
purchases = p;
payouts = py;
} catch (e) {
error = e instanceof CardsApiError ? e.message : (e as Error).message;
} finally {
loading = false;
}
});
</script>
<svelte:head>
<title>Meine Käufe — Cards</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-6 py-8">
<h1 class="mb-6 text-2xl font-semibold tracking-tight">Käufe & Auszahlungen</h1>
{#if error}
<p class="mb-4 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
{error}
</p>
{/if}
<section class="mb-10">
<header class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-neutral-400">Käufe</h2>
<span class="text-xs text-neutral-500">Ausgegeben: {totalSpent} 💎</span>
</header>
{#if loading}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
Lädt…
</p>
{:else if purchases.length === 0}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
Du hast noch keine Decks gekauft.
</p>
{:else}
<ul class="space-y-2">
{#each purchases as p (p.id)}
<li
class="flex items-center justify-between rounded-xl border border-neutral-800 bg-neutral-900 p-4"
>
<div class="min-w-0 flex-1">
<a
href="/d/{p.deckSlug}"
class="truncate font-medium text-neutral-100 hover:text-indigo-300"
>
{p.deckTitle}
</a>
<p class="mt-1 text-xs text-neutral-500">
v{p.versionSemver} · {new Date(p.purchasedAt).toLocaleDateString('de-DE')}
{#if p.refundedAt}
<span class="ml-2 rounded bg-amber-500/15 px-1.5 py-0.5 text-amber-300"
>Erstattet</span
>
{/if}
</p>
</div>
<span class="shrink-0 text-sm text-neutral-300">{p.priceCredits} 💎</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if payouts.length > 0 || (!loading && payouts.length === 0)}
<section>
<header class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wide text-neutral-400">
Author-Auszahlungen
</h2>
<span class="text-xs text-neutral-500">Erhalten: {totalEarned} 💎</span>
</header>
{#if payouts.length === 0}
<p class="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-sm text-neutral-500">
Noch keine Auszahlungen — sobald jemand eines deiner kostenpflichtigen Decks kauft, landet
die Author-Beteiligung hier.
</p>
{:else}
<ul class="space-y-2">
{#each payouts as p (p.id)}
<li
class="flex items-center justify-between rounded-xl border border-neutral-800 bg-neutral-900 p-4"
>
<div class="min-w-0 flex-1">
<a
href="/d/{p.deckSlug}"
class="truncate font-medium text-neutral-100 hover:text-indigo-300"
>
{p.deckTitle}
</a>
<p class="mt-1 text-xs text-neutral-500">
Verkauf {p.priceCredits} 💎 · gutgeschrieben {new Date(
p.grantedAt
).toLocaleDateString('de-DE')}
</p>
</div>
<span class="shrink-0 text-sm text-emerald-300">+{p.creditsGranted} 💎</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</main>

View file

@ -23,6 +23,7 @@ import { EngagementService } from './services/engagement';
import { SubscriptionService } from './services/subscriptions';
import { PullRequestService } from './services/pull-requests';
import { DiscussionService } from './services/discussions';
import { PurchaseService } from './services/purchases';
import { createAuthorRoutes } from './routes/authors';
import { createDeckRoutes } from './routes/decks';
import { createExploreRoutes } from './routes/explore';
@ -30,7 +31,9 @@ import { createEngagementRoutes } from './routes/engagement';
import { createSubscriptionRoutes } from './routes/subscriptions';
import { createPullRequestRoutes } from './routes/pull-requests';
import { createDiscussionRoutes } from './routes/discussions';
import { createPurchaseRoutes } from './routes/purchases';
import { createNotifyClient } from './lib/notify';
import { createCreditsClient } from './lib/credits';
// ─── Bootstrap ──────────────────────────────────────────────
@ -42,6 +45,11 @@ const notify = createNotifyClient({
serviceKey: config.serviceKey,
});
const credits = createCreditsClient({
url: config.manaCreditsUrl,
serviceKey: config.serviceKey,
});
const authorService = new AuthorService(db);
const deckService = new DeckService(db, config.manaLlmUrl);
const exploreService = new ExploreService(db);
@ -49,6 +57,15 @@ const engagementService = new EngagementService(db);
const subscriptionService = new SubscriptionService(db);
const pullRequestService = new PullRequestService(db, notify);
const discussionService = new DiscussionService(db);
const purchaseService = new PurchaseService(
db,
credits,
{
standardAuthorBps: config.authorPayout.standardAuthorBps,
verifiedAuthorBps: config.authorPayout.verifiedAuthorBps,
},
notify
);
// ─── App ────────────────────────────────────────────────────
@ -91,8 +108,9 @@ v1.route('/', createEngagementRoutes(engagementService));
v1.route('/', createSubscriptionRoutes(subscriptionService));
v1.route('/', createPullRequestRoutes(pullRequestService));
v1.route('/', createDiscussionRoutes(discussionService));
v1.route('/', createPurchaseRoutes(purchaseService));
v1.route('/authors', createAuthorRoutes(authorService));
v1.route('/decks', createDeckRoutes(authorService, deckService));
v1.route('/decks', createDeckRoutes(authorService, deckService, purchaseService));
v1.get('/', (c) =>
c.json({

View file

@ -0,0 +1,80 @@
/**
* Thin client for mana-credits internal API. Cards-server is a
* service-to-service caller the buyer's JWT does not flow through
* here; we use the X-Service-Key channel instead so we can reserve
* credits on a user's behalf, commit them after the purchase row is
* written, and grant the author share in one server-side flow.
*
* Errors propagate as Error subclasses so the purchase service can
* branch on `InsufficientCredits` vs. infra failures.
*/
export class CreditsClientError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message: string
) {
super(message);
this.name = 'CreditsClientError';
}
}
export class InsufficientCreditsError extends CreditsClientError {
constructor(message: string) {
super(402, 'insufficient_credits', message);
this.name = 'InsufficientCreditsError';
}
}
export interface CreditsClient {
reserve(input: { userId: string; amount: number; reason: string }): Promise<{
reservationId: string;
balance: number;
}>;
commit(input: { reservationId: string; description?: string }): Promise<unknown>;
refundReservation(input: { reservationId: string }): Promise<unknown>;
grant(input: {
userId: string;
amount: number;
reason: string;
referenceId: string;
description?: string;
}): Promise<{ transactionId?: string; grantId?: string } | unknown>;
}
export function createCreditsClient(opts: { url: string; serviceKey: string }): CreditsClient {
async function call<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${opts.url}/api/v1/internal${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Service-Key': opts.serviceKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
let code = 'credits_error';
try {
const j = (await res.json()) as { code?: string; message?: string };
if (j.message) msg = j.message;
if (j.code) code = j.code;
} catch {
/* keep default */
}
if (res.status === 402 || code === 'insufficient_credits') {
throw new InsufficientCreditsError(msg);
}
throw new CreditsClientError(res.status, code, msg);
}
return (await res.json()) as T;
}
return {
reserve: (input) => call('/credits/reserve', input),
commit: (input) => call('/credits/commit', input),
refundReservation: (input) => call('/credits/refund-reservation', input),
grant: (input) => call('/credits/grant', input),
};
}

View file

@ -3,6 +3,7 @@ import { z } from 'zod';
import type { AuthUser } from '../middleware/jwt-auth';
import type { AuthorService } from '../services/authors';
import type { DeckService } from '../services/decks';
import type { PurchaseService } from '../services/purchases';
import { BadRequestError, UnauthorizedError } from '../lib/errors';
const cardTypes = [
@ -43,7 +44,11 @@ function requireUser(user: AuthUser | undefined): AuthUser {
return user;
}
export function createDeckRoutes(authorService: AuthorService, deckService: DeckService) {
export function createDeckRoutes(
authorService: AuthorService,
deckService: DeckService,
purchaseService?: PurchaseService
) {
const router = new Hono<{ Variables: { user?: AuthUser } }>();
// Init = write, auth required.
@ -56,10 +61,17 @@ export function createDeckRoutes(authorService: AuthorService, deckService: Deck
return c.json(deck, 201);
});
// GET deck-by-slug is public — anyone can preview a deck.
// GET deck-by-slug is public — anyone can preview a deck. If a
// JWT is present we also annotate `hasPurchased` so the buy
// button can be hidden for owners.
router.get('/:slug', async (c) => {
const result = await deckService.getBySlug(c.req.param('slug'));
return c.json(result);
const user = c.get('user');
const hasPurchased =
user?.userId && purchaseService
? await purchaseService.hasPurchased(user.userId, result.deck.id)
: null;
return c.json({ ...result, hasPurchased });
});
router.post('/:slug/publish', async (c) => {

View file

@ -0,0 +1,33 @@
import { Hono } from 'hono';
import type { AuthUser } from '../middleware/jwt-auth';
import type { PurchaseService } from '../services/purchases';
import { UnauthorizedError } from '../lib/errors';
function requireUser(user: AuthUser | undefined): AuthUser {
if (!user || !user.userId) throw new UnauthorizedError();
return user;
}
export function createPurchaseRoutes(service: PurchaseService) {
const router = new Hono<{ Variables: { user?: AuthUser } }>();
router.post('/decks/:slug/purchase', async (c) => {
const user = requireUser(c.get('user'));
const result = await service.purchase(user.userId, c.req.param('slug'));
return c.json(result, result.alreadyOwned ? 200 : 201);
});
router.get('/me/purchases', async (c) => {
const user = requireUser(c.get('user'));
const list = await service.listForBuyer(user.userId);
return c.json(list);
});
router.get('/authors/me/payouts', async (c) => {
const user = requireUser(c.get('user'));
const list = await service.listPayoutsForAuthor(user.userId);
return c.json(list);
});
return router;
}

View file

@ -0,0 +1,233 @@
/**
* Paid-deck purchase pipeline. Phase ζ.1 buyer pays, author gets
* the configured share, Mana keeps the rest. Lifetime access per
* (buyer, deck) same row covers all future versions of the deck.
*
* The flow is two-phase against mana-credits:
*
* 1. reserve(buyer, price) atomic balance check + hold
* 2. INSERT deck_purchases row
* 3. commit(reservationId) finalise the buyer-side debit
* 4. grant(author, authorShare) author payout
* 5. INSERT author_payouts row
*
* If step 3 or 4 fails after the purchase row exists, we leave the
* row alone (idempotency relies on the unique (buyer, deck) index).
* A future reconciler can sweep purchase rows whose
* `creditsTransaction` is null and either commit-retry or roll back
* via a manual refund.
*/
import { and, desc, eq } from 'drizzle-orm';
import type { Database } from '../db/connection';
import {
authorPayouts,
authors,
deckPurchases,
publicDecks,
publicDeckVersions,
} from '../db/schema';
import { BadRequestError, ForbiddenError, NotFoundError } from '../lib/errors';
import type { CreditsClient } from '../lib/credits';
import { InsufficientCreditsError } from '../lib/credits';
import type { NotifyClient } from '../lib/notify';
interface PurchaseConfig {
standardAuthorBps: number;
verifiedAuthorBps: number;
}
export class PurchaseService {
constructor(
private readonly db: Database,
private readonly credits: CreditsClient,
private readonly config: PurchaseConfig,
private readonly notify?: NotifyClient
) {}
/**
* Idempotent: if the buyer already owns the deck, returns the
* existing purchase row without touching mana-credits.
*/
async purchase(buyerUserId: string, deckSlug: string) {
const deck = await this.db.query.publicDecks.findFirst({
where: eq(publicDecks.slug, deckSlug),
});
if (!deck) throw new NotFoundError('Deck not found');
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
if (deck.priceCredits <= 0) {
throw new BadRequestError('Deck is free — no purchase required');
}
if (deck.ownerUserId === buyerUserId) {
throw new BadRequestError('Cannot purchase your own deck');
}
if (!deck.latestVersionId) {
throw new BadRequestError('Deck has no published version');
}
// Idempotency.
const existing = await this.db.query.deckPurchases.findFirst({
where: and(eq(deckPurchases.buyerUserId, buyerUserId), eq(deckPurchases.deckId, deck.id)),
});
if (existing) {
if (existing.refundedAt) {
throw new BadRequestError('Purchase was previously refunded');
}
return { purchase: existing, alreadyOwned: true };
}
const author = await this.db.query.authors.findFirst({
where: eq(authors.userId, deck.ownerUserId),
});
if (!author) throw new NotFoundError('Author profile missing');
// Author share split — verified-mana authors get a higher cut.
const authorBps = author.verifiedMana
? this.config.verifiedAuthorBps
: this.config.standardAuthorBps;
const authorShare = Math.floor((deck.priceCredits * authorBps) / 10_000);
const manaShare = deck.priceCredits - authorShare;
// Step 1 — reserve.
let reservationId: string;
try {
const reservation = await this.credits.reserve({
userId: buyerUserId,
amount: deck.priceCredits,
reason: `cards.deck-purchase:${deck.slug}`,
});
reservationId = reservation.reservationId;
} catch (e) {
if (e instanceof InsufficientCreditsError) throw e;
throw e;
}
// Step 2 — write the purchase row.
let purchase: typeof deckPurchases.$inferSelect;
try {
[purchase] = await this.db
.insert(deckPurchases)
.values({
buyerUserId,
deckId: deck.id,
versionId: deck.latestVersionId,
priceCredits: deck.priceCredits,
authorShare,
manaShare,
})
.returning();
} catch (insertErr) {
// Rollback the reservation so the buyer's credits aren't held.
await this.credits
.refundReservation({ reservationId })
.catch((refundErr) =>
console.warn('[purchases] reservation refund after insert-fail failed', refundErr)
);
throw insertErr;
}
// Step 3 — commit the buyer-side debit.
try {
await this.credits.commit({
reservationId,
description: `Cards: ${deck.title} (${deck.slug})`,
});
} catch (commitErr) {
console.warn('[purchases] commit failed — purchase row remains for reconciler', commitErr);
throw commitErr;
}
// Step 4 — grant the author share. Failures here don't affect
// the buyer's access (they already paid + got the row); we log
// and rely on the reconciler to retry the grant.
let payoutRow: typeof authorPayouts.$inferSelect | null = null;
if (authorShare > 0) {
try {
const granted = (await this.credits.grant({
userId: deck.ownerUserId,
amount: authorShare,
reason: 'cards.author-payout',
referenceId: purchase.id,
description: `Cards-Verkauf: ${deck.title}`,
})) as { transactionId?: string };
[payoutRow] = await this.db
.insert(authorPayouts)
.values({
authorUserId: deck.ownerUserId,
sourcePurchaseId: purchase.id,
creditsGranted: authorShare,
creditsGrantId: granted?.transactionId ?? null,
})
.returning();
} catch (grantErr) {
console.warn('[purchases] author grant failed — will retry via reconciler', grantErr);
}
}
if (this.notify) {
void this.notify.send({
channel: 'email',
userId: deck.ownerUserId,
subject: `Verkauf: „${deck.title}"`,
body: `Ein neuer Käufer hat dein Deck „${deck.title}" gekauft. Du hast ${authorShare} Credits gutgeschrieben bekommen.`,
data: {
type: 'cards.deck.purchased',
deckSlug: deck.slug,
purchaseId: purchase.id,
authorShare,
},
externalId: `cards.deck.purchased.${purchase.id}`,
});
}
return { purchase, payout: payoutRow, alreadyOwned: false };
}
async hasPurchased(buyerUserId: string, deckId: string): Promise<boolean> {
const row = await this.db.query.deckPurchases.findFirst({
where: and(eq(deckPurchases.buyerUserId, buyerUserId), eq(deckPurchases.deckId, deckId)),
});
return !!row && !row.refundedAt;
}
async listForBuyer(buyerUserId: string) {
const rows = await this.db
.select({
id: deckPurchases.id,
deckId: deckPurchases.deckId,
deckSlug: publicDecks.slug,
deckTitle: publicDecks.title,
priceCredits: deckPurchases.priceCredits,
purchasedAt: deckPurchases.purchasedAt,
refundedAt: deckPurchases.refundedAt,
versionId: deckPurchases.versionId,
versionSemver: publicDeckVersions.semver,
})
.from(deckPurchases)
.innerJoin(publicDecks, eq(deckPurchases.deckId, publicDecks.id))
.innerJoin(publicDeckVersions, eq(deckPurchases.versionId, publicDeckVersions.id))
.where(eq(deckPurchases.buyerUserId, buyerUserId))
.orderBy(desc(deckPurchases.purchasedAt));
return rows;
}
async listPayoutsForAuthor(authorUserId: string) {
const rows = await this.db
.select({
id: authorPayouts.id,
purchaseId: authorPayouts.sourcePurchaseId,
creditsGranted: authorPayouts.creditsGranted,
grantedAt: authorPayouts.grantedAt,
deckSlug: publicDecks.slug,
deckTitle: publicDecks.title,
priceCredits: deckPurchases.priceCredits,
})
.from(authorPayouts)
.innerJoin(deckPurchases, eq(authorPayouts.sourcePurchaseId, deckPurchases.id))
.innerJoin(publicDecks, eq(deckPurchases.deckId, publicDecks.id))
.where(eq(authorPayouts.authorUserId, authorUserId))
.orderBy(desc(authorPayouts.grantedAt));
return rows;
}
}

View file

@ -18,7 +18,13 @@
import { and, asc, eq } from 'drizzle-orm';
import type { Database } from '../db/connection';
import { deckSubscriptions, publicDeckCards, publicDeckVersions, publicDecks } from '../db/schema';
import {
deckPurchases,
deckSubscriptions,
publicDeckCards,
publicDeckVersions,
publicDecks,
} from '../db/schema';
import { ConflictError, ForbiddenError, NotFoundError } from '../lib/errors';
export interface VersionPayload {
@ -56,9 +62,16 @@ export class SubscriptionService {
if (!deck) throw new NotFoundError('Deck not found');
if (deck.isTakedown) throw new ForbiddenError('Deck under takedown');
if (!deck.latestVersionId) throw new ConflictError('Deck has no published version yet');
// Paid decks need a purchase first — Phase ζ. For now: refuse.
if (deck.priceCredits > 0) {
throw new ForbiddenError('Paid decks require a purchase before subscribing (Phase ζ)');
// Paid decks need a non-refunded purchase before the user can
// subscribe (= pull the cards). The author themselves can
// always subscribe to their own paid deck for testing.
if (deck.priceCredits > 0 && deck.ownerUserId !== userId) {
const purchase = await this.db.query.deckPurchases.findFirst({
where: and(eq(deckPurchases.buyerUserId, userId), eq(deckPurchases.deckId, deck.id)),
});
if (!purchase || purchase.refundedAt) {
throw new ForbiddenError('Paid deck — purchase required before subscribing');
}
}
await this.db