mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
4fcc15737f
commit
5dbc9ace2d
10 changed files with 627 additions and 12 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
132
apps/cards/apps/web/src/routes/me/purchases/+page.svelte
Normal file
132
apps/cards/apps/web/src/routes/me/purchases/+page.svelte
Normal 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>
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
80
services/cards-server/src/lib/credits.ts
Normal file
80
services/cards-server/src/lib/credits.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
33
services/cards-server/src/routes/purchases.ts
Normal file
33
services/cards-server/src/routes/purchases.ts
Normal 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;
|
||||
}
|
||||
233
services/cards-server/src/services/purchases.ts
Normal file
233
services/cards-server/src/services/purchases.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue