mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +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
|
|
@ -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