diff --git a/apps/cards/apps/web/src/lib/api/cards-api.ts b/apps/cards/apps/web/src/lib/api/cards-api.ts index 134942c41..2f3c286c4 100644 --- a/apps/cards/apps/web/src/lib/api/cards-api.ts +++ b/apps/cards/apps/web/src/lib/api/cards-api.ts @@ -133,10 +133,11 @@ export const cardsApi = { priceCredits?: number; }) => request('/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(`/v1/decks/${encodeURIComponent(deckSlug)}/purchase`, { + method: 'POST', + body: {}, + }), + listMine: () => request('/v1/me/purchases'), + }, + payouts: { + listMine: () => request('/v1/authors/me/payouts'), + }, discussions: { countsForDeck: (deckSlug: string) => request>( @@ -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; diff --git a/apps/cards/apps/web/src/routes/+layout.svelte b/apps/cards/apps/web/src/routes/+layout.svelte index 7d6d19cf0..3a798de6c 100644 --- a/apps/cards/apps/web/src/routes/+layout.svelte +++ b/apps/cards/apps/web/src/routes/+layout.svelte @@ -54,6 +54,7 @@
{#if streak > 0} diff --git a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte index bc1d39579..d7c8b1a89 100644 --- a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte +++ b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte @@ -23,8 +23,14 @@ let subscribed = $state(false); let subscribeBusy = $state(false); let subscribedDeckId = $state(null); + let hasPurchased = $state(null); + let purchaseBusy = $state(false); let error = $state(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 {/if} + {:else if isPaid && !canSubscribeNow && !isOwner} + {:else} + {#if isPaid && hasPurchased} + + ✓ Gekauft + + {/if} {/if} {:else} + import { onMount } from 'svelte'; + import { + cardsApi, + CardsApiError, + type BuyerPurchase, + type AuthorPayout, + } from '$lib/api/cards-api'; + + let purchases = $state([]); + let payouts = $state([]); + let loading = $state(true); + let error = $state(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; + } + }); + + + + Meine Käufe — Cards + + +
+

Käufe & Auszahlungen

+ + {#if error} +

+ {error} +

+ {/if} + +
+
+

Käufe

+ Ausgegeben: {totalSpent} 💎 +
+ + {#if loading} +

+ Lädt… +

+ {:else if purchases.length === 0} +

+ Du hast noch keine Decks gekauft. +

+ {:else} +
+ {/if} +
+ + {#if payouts.length > 0 || (!loading && payouts.length === 0)} +
+
+

+ Author-Auszahlungen +

+ Erhalten: {totalEarned} 💎 +
+ + {#if payouts.length === 0} +

+ Noch keine Auszahlungen — sobald jemand eines deiner kostenpflichtigen Decks kauft, landet + die Author-Beteiligung hier. +

+ {:else} +
    + {#each payouts as p (p.id)} +
  • +
    + + {p.deckTitle} + +

    + Verkauf {p.priceCredits} 💎 · gutgeschrieben {new Date( + p.grantedAt + ).toLocaleDateString('de-DE')} +

    +
    + +{p.creditsGranted} 💎 +
  • + {/each} +
+ {/if} +
+ {/if} +
diff --git a/services/cards-server/src/index.ts b/services/cards-server/src/index.ts index d9695fb68..6db03357f 100644 --- a/services/cards-server/src/index.ts +++ b/services/cards-server/src/index.ts @@ -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({ diff --git a/services/cards-server/src/lib/credits.ts b/services/cards-server/src/lib/credits.ts new file mode 100644 index 000000000..864f9dd73 --- /dev/null +++ b/services/cards-server/src/lib/credits.ts @@ -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; + refundReservation(input: { reservationId: string }): Promise; + 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(path: string, body: unknown): Promise { + 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), + }; +} diff --git a/services/cards-server/src/routes/decks.ts b/services/cards-server/src/routes/decks.ts index 8b0d38605..87fea506c 100644 --- a/services/cards-server/src/routes/decks.ts +++ b/services/cards-server/src/routes/decks.ts @@ -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) => { diff --git a/services/cards-server/src/routes/purchases.ts b/services/cards-server/src/routes/purchases.ts new file mode 100644 index 000000000..56e9a34cf --- /dev/null +++ b/services/cards-server/src/routes/purchases.ts @@ -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; +} diff --git a/services/cards-server/src/services/purchases.ts b/services/cards-server/src/services/purchases.ts new file mode 100644 index 000000000..7e8275324 --- /dev/null +++ b/services/cards-server/src/services/purchases.ts @@ -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 { + 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; + } +} diff --git a/services/cards-server/src/services/subscriptions.ts b/services/cards-server/src/services/subscriptions.ts index 45e74b386..07b1ae5c9 100644 --- a/services/cards-server/src/services/subscriptions.ts +++ b/services/cards-server/src/services/subscriptions.ts @@ -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