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>