refactor(credits): simplify credit system — remove productivity credits, guild pools, complex gift types

The credit system was overengineered for the local-first architecture:
- Productivity micro-credits (task/event/contact creation at 0.02 credits) made no sense
  since these operations happen locally in IndexedDB with zero server cost and were never enforced
- Guild pool system (6 DB tables, spending limits, membership checks) had no active users
- Gift system had 5 types (simple/personalized/split/first_come/riddle) when 2 suffice

Now credits are only charged for operations that actually cost money: AI API calls and
premium features (sync, exports). This makes the value proposition clear to users.

Changes:
- Remove 8 productivity operations + CreditCategory.PRODUCTIVITY from @mana/credits
- Delete guild pool service, routes, schema (3 files); remove guild refs from 8 backend files
- Simplify gifts to simple + personalized only; remove bcrypt/riddle/portions logic
- Update all frontend pages (credits dashboard, gift create/redeem, public gift page)
- Update shared-hono consumeCredits() to remove creditSource parameter
- Update mana-credits CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 19:08:42 +02:00
parent 29ad31c4ed
commit e068335dd4
32 changed files with 143 additions and 922 deletions

View file

@ -65,23 +65,38 @@ apps/{project}/apps/web/
### Derived Values with $derived ### Derived Values with $derived
**CRITICAL: `$derived(expr)` vs `$derived.by(fn)`**
- `$derived(expression)` — takes a **single expression**. The value IS the expression result.
- `$derived.by(() => { ... return value; })` — takes a **function** (thunk). Use this when you need `if`/`switch`/`for` or multiple statements.
**Common mistake:** writing `$derived(() => { ... })` — this stores the arrow function itself as the value, not its return value. Every `{#if myDerived}` will be truthy (functions are always truthy), and `myDerived()` will fail with "not callable" at the type level.
```svelte ```svelte
<script lang="ts"> <script lang="ts">
let count = $state(0); let count = $state(0);
let items = $state<Item[]>([]); let items = $state<Item[]>([]);
// Computed value - updates automatically // ✅ Single expression → $derived
const doubled = $derived(count * 2); const doubled = $derived(count * 2);
const itemCount = $derived(items.length); const itemCount = $derived(items.length);
const hasItems = $derived(items.length > 0); const hasItems = $derived(items.length > 0);
// Complex derived
const sortedItems = $derived([...items].sort((a, b) => a.name.localeCompare(b.name))); const sortedItems = $derived([...items].sort((a, b) => a.name.localeCompare(b.name)));
// Derived with conditions // ✅ Ternary is still a single expression
const displayText = $derived( const displayText = $derived(
count === 0 ? 'No items' : count === 1 ? '1 item' : `${count} items` count === 0 ? 'No items' : count === 1 ? '1 item' : `${count} items`
); );
// ✅ Multi-statement logic → $derived.by
const filteredItems = $derived.by(() => {
if (!searchQuery.trim()) return items;
const q = searchQuery.toLowerCase();
return items.filter((i) => i.name.toLowerCase().includes(q));
});
// ❌ WRONG — stores the function, not the result!
// const filteredItems = $derived(() => { ... });
</script> </script>
``` ```

View file

@ -9,4 +9,27 @@ export default [
...typescriptConfig, ...typescriptConfig,
...svelteConfig, ...svelteConfig,
...prettierConfig, ...prettierConfig,
// Guard: prevent raw liveQuery imports in module code. All modules
// must use useLiveQueryWithDefault from @mana/local-store/svelte
// instead, which provides a reactive { value, loading, error } shape.
// Raw liveQuery returns an Observable that requires manual .subscribe()
// boilerplate and was the root cause of 38 type errors.
{
files: ['src/lib/modules/**/queries.ts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'dexie',
importNames: ['liveQuery'],
message:
'Use useLiveQueryWithDefault from @mana/local-store/svelte instead of raw liveQuery. See the Observable migration commit for rationale.',
},
],
},
],
},
},
]; ];

View file

@ -9,15 +9,11 @@ import { getManaAuthUrl } from './config';
// Types // Types
export interface GiftCodeInfo { export interface GiftCodeInfo {
code: string; code: string;
type: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; type: 'simple' | 'personalized';
status: 'active' | 'depleted' | 'expired' | 'cancelled' | 'refunded'; status: 'active' | 'depleted' | 'expired' | 'cancelled' | 'refunded';
creditsPerPortion: number; totalCredits: number;
totalPortions: number; redeemed: boolean;
claimedPortions: number;
remainingPortions: number;
message?: string; message?: string;
riddleQuestion?: string;
hasRiddle: boolean;
isPersonalized: boolean; isPersonalized: boolean;
expiresAt?: string; expiresAt?: string;
creatorName?: string; creatorName?: string;
@ -38,9 +34,7 @@ export interface GiftListItem {
type: string; type: string;
status: string; status: string;
totalCredits: number; totalCredits: number;
creditsPerPortion: number; redeemed: number;
totalPortions: number;
claimedPortions: number;
message?: string; message?: string;
expiresAt?: string; expiresAt?: string;
createdAt: string; createdAt: string;
@ -60,19 +54,14 @@ export interface CreateGiftResponse {
code: string; code: string;
url: string; url: string;
totalCredits: number; totalCredits: number;
creditsPerPortion: number;
totalPortions: number;
type: string; type: string;
expiresAt?: string; expiresAt?: string;
} }
export interface CreateGiftRequest { export interface CreateGiftRequest {
credits: number; credits: number;
type?: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; type?: 'simple' | 'personalized';
portions?: number;
targetEmail?: string; targetEmail?: string;
riddleQuestion?: string;
riddleAnswer?: string;
message?: string; message?: string;
expiresAt?: string; expiresAt?: string;
sourceAppId?: string; sourceAppId?: string;
@ -127,10 +116,10 @@ export const giftsService = {
/** /**
* Redeem a gift code * Redeem a gift code
*/ */
async redeemGift(code: string, answer?: string): Promise<GiftRedeemResponse> { async redeemGift(code: string): Promise<GiftRedeemResponse> {
return fetchWithAuth<GiftRedeemResponse>(`/api/v1/gifts/${code.toUpperCase()}/redeem`, { return fetchWithAuth<GiftRedeemResponse>(`/api/v1/gifts/${code.toUpperCase()}/redeem`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ answer, sourceAppId: 'mana-web' }), body: JSON.stringify({ sourceAppId: 'mana-web' }),
}); });
}, },
@ -159,7 +148,7 @@ export const giftsService = {
}, },
/** /**
* Cancel a gift code and get refund for unclaimed portions * Cancel a gift code and get refund
*/ */
async cancelGift(id: string): Promise<{ refundedCredits: number }> { async cancelGift(id: string): Promise<{ refundedCredits: number }> {
return fetchWithAuth<{ refundedCredits: number }>(`/api/v1/gifts/${id}`, { return fetchWithAuth<{ refundedCredits: number }>(`/api/v1/gifts/${id}`, {

View file

@ -228,6 +228,9 @@ db.version(1).stores({
habits: 'id, order, isArchived, color', habits: 'id, order, isArchived, color',
habitLogs: 'id, habitId, timeBlockId, [habitId+timeBlockId]', habitLogs: 'id, habitId, timeBlockId, [habitId+timeBlockId]',
// ─── Journal (appId: 'journal') ───
journalEntries: 'id, entryDate, mood, isPinned, isArchived, isFavorite, updatedAt',
// ─── Dreams (appId: 'dreams') ─── // ─── Dreams (appId: 'dreams') ───
dreams: 'id, dreamDate, mood, isLucid, isPinned, isArchived, updatedAt', dreams: 'id, dreamDate, mood, isLucid, isPinned, isArchived, updatedAt',
dreamSymbols: 'id, name, count, updatedAt', dreamSymbols: 'id, name, count, updatedAt',
@ -356,6 +359,14 @@ db.version(4).stores({
newsCachedFeed: 'id, topic, sourceSlug, language, publishedAt, [topic+publishedAt]', newsCachedFeed: 'id, topic, sourceSlug, language, publishedAt, [topic+publishedAt]',
}); });
// Schema version 5 — adds timeBlockId index to bodyWorkouts so the
// calendar/timeline integration (createBlock in startWorkout) can
// look up "which workout owns this TimeBlock" via a Dexie index
// instead of a full-table scan + filter. Additive only.
db.version(5).stores({
bodyWorkouts: 'id, startedAt, endedAt, routineId, timeBlockId, [endedAt+startedAt]',
});
// v5: Zitare custom quotes — user-created quotes stored locally. // v5: Zitare custom quotes — user-created quotes stored locally.
db.version(5).stores({ db.version(5).stores({
zitareCustomQuotes: 'id, author, category', zitareCustomQuotes: 'id, author, category',

View file

@ -77,6 +77,7 @@ describe('calendarsStore.setAsDefault', () => {
isDefault: c.isDefault as boolean, isDefault: c.isDefault as boolean,
isVisible: c.isVisible as boolean, isVisible: c.isVisible as boolean,
color: c.color as string, color: c.color as string,
timezone: (c.timezone as string) ?? null,
createdAt: (c.createdAt as string) ?? '', createdAt: (c.createdAt as string) ?? '',
updatedAt: (c.updatedAt as string) ?? '', updatedAt: (c.updatedAt as string) ?? '',
})); }));

View file

@ -24,4 +24,5 @@ export { guideTable, sectionTable, stepTable, runTable, GUIDES_GUEST_SEED } from
// TODO: GUIDES should be populated from a content source (static JSON, CMS, or DB). // TODO: GUIDES should be populated from a content source (static JSON, CMS, or DB).
// For now export an empty array so the /guides route renders without crashing the build. // For now export an empty array so the /guides route renders without crashing the build.
export const GUIDES: Guide[] = []; import type { Guide as GuideType } from './types';
export const GUIDES: GuideType[] = [];

View file

@ -46,7 +46,8 @@ export type {
LocalMemoSpace, LocalMemoSpace,
Memo, Memo,
Memory, Memory,
Tag,
Space, Space,
ProcessingStatus, ProcessingStatus,
} from './types'; } from './types';
// Tag type re-exported from @mana/shared-tags (the local memoro Tag was removed)
export type { Tag } from '@mana/shared-tags';

View file

@ -24,7 +24,7 @@
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let activeTab = $state<'overview' | 'transactions' | 'packages' | 'costs'>('overview'); let activeTab = $state<'overview' | 'transactions' | 'packages' | 'costs'>('overview');
let costFilter = $state<'all' | 'ai' | 'productivity' | 'premium'>('all'); let costFilter = $state<'all' | 'ai' | 'premium'>('all');
// Build pricing data grouped by app // Build pricing data grouped by app
const allOperations = $derived( const allOperations = $derived(
@ -44,7 +44,6 @@
? allOperations ? allOperations
: allOperations.filter((op) => { : allOperations.filter((op) => {
if (costFilter === 'ai') return op.category === CreditCategory.AI; if (costFilter === 'ai') return op.category === CreditCategory.AI;
if (costFilter === 'productivity') return op.category === CreditCategory.PRODUCTIVITY;
if (costFilter === 'premium') return op.category === CreditCategory.PREMIUM; if (costFilter === 'premium') return op.category === CreditCategory.PREMIUM;
return true; return true;
}) })
@ -86,8 +85,6 @@
switch (category) { switch (category) {
case CreditCategory.AI: case CreditCategory.AI:
return 'KI-Features'; return 'KI-Features';
case CreditCategory.PRODUCTIVITY:
return 'Erstellen';
case CreditCategory.PREMIUM: case CreditCategory.PREMIUM:
return 'Premium'; return 'Premium';
default: default:
@ -486,7 +483,7 @@
{:else if activeTab === 'costs'} {:else if activeTab === 'costs'}
<!-- Category Filter --> <!-- Category Filter -->
<div class="flex flex-wrap gap-2 mb-6"> <div class="flex flex-wrap gap-2 mb-6">
{#each [{ key: 'all', label: 'Alle' }, { key: 'ai', label: 'KI-Features' }, { key: 'productivity', label: 'Erstellen' }, { key: 'premium', label: 'Premium' }] as filter} {#each [{ key: 'all', label: 'Alle' }, { key: 'ai', label: 'KI-Features' }, { key: 'premium', label: 'Premium' }] as filter}
<button <button
onclick={() => (costFilter = filter.key as typeof costFilter)} onclick={() => (costFilter = filter.key as typeof costFilter)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors {costFilter === class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors {costFilter ===

View file

@ -21,11 +21,9 @@
// Create form state // Create form state
let createCredits = $state(50); let createCredits = $state(50);
let createType = $state<'simple' | 'split' | 'riddle'>('simple'); let createType = $state<'simple' | 'personalized'>('simple');
let createPortions = $state(1); let createTargetEmail = $state('');
let createMessage = $state(''); let createMessage = $state('');
let createRiddleQuestion = $state('');
let createRiddleAnswer = $state('');
let creating = $state(false); let creating = $state(false);
let createError = $state<string | null>(null); let createError = $state<string | null>(null);
let createdGift = $state<{ code: string; url: string } | null>(null); let createdGift = $state<{ code: string; url: string } | null>(null);
@ -74,8 +72,8 @@
return; return;
} }
if (createType === 'riddle' && (!createRiddleQuestion.trim() || !createRiddleAnswer.trim())) { if (createType === 'personalized' && !createTargetEmail.trim()) {
createError = 'Frage und Antwort sind für Rätsel-Geschenke erforderlich'; createError = 'E-Mail-Adresse ist für persönliche Geschenke erforderlich';
return; return;
} }
@ -86,11 +84,9 @@
try { try {
const request: CreateGiftRequest = { const request: CreateGiftRequest = {
credits: createCredits, credits: createCredits,
type: createType === 'split' ? 'split' : createType, type: createType,
portions: createType === 'split' ? createPortions : 1, targetEmail: createType === 'personalized' ? createTargetEmail.trim() : undefined,
message: createMessage.trim() || undefined, message: createMessage.trim() || undefined,
riddleQuestion: createType === 'riddle' ? createRiddleQuestion.trim() : undefined,
riddleAnswer: createType === 'riddle' ? createRiddleAnswer.trim() : undefined,
}; };
const result = await giftsService.createGift(request); const result = await giftsService.createGift(request);
@ -100,10 +96,8 @@
// Reset form // Reset form
createCredits = 50; createCredits = 50;
createType = 'simple'; createType = 'simple';
createPortions = 1; createTargetEmail = '';
createMessage = ''; createMessage = '';
createRiddleQuestion = '';
createRiddleAnswer = '';
// Reload data // Reload data
await loadData(); await loadData();
@ -199,14 +193,8 @@
switch (type) { switch (type) {
case 'simple': case 'simple':
return 'Einfach'; return 'Einfach';
case 'split':
return 'Geteilt';
case 'riddle':
return 'Rätsel';
case 'personalized': case 'personalized':
return 'Persönlich'; return 'Persönlich';
case 'first_come':
return 'Erste kommen';
default: default:
return type; return type;
} }
@ -406,7 +394,7 @@
</div> </div>
<div> <div>
<p class="text-muted-foreground">Eingelöst</p> <p class="text-muted-foreground">Eingelöst</p>
<p class="font-medium">{gift.claimedPortions} / {gift.totalPortions}</p> <p class="font-medium">{gift.redeemed ? 'Ja' : 'Nein'}</p>
</div> </div>
<div> <div>
<p class="text-muted-foreground">Erstellt</p> <p class="text-muted-foreground">Erstellt</p>
@ -466,63 +454,30 @@
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
disabled={creating} disabled={creating}
> >
<option value="simple">Einfach (1 Person)</option> <option value="simple">Einfach (Code teilen)</option>
<option value="split">Geteilt (mehrere Personen)</option> <option value="personalized">Persönlich (für bestimmte E-Mail)</option>
<option value="riddle">Mit Rätsel</option>
</select> </select>
</div> </div>
{#if createType === 'split'} {#if createType === 'personalized'}
<div> <div>
<label for="portions" class="block text-sm font-medium text-foreground mb-2"> <label for="target-email" class="block text-sm font-medium text-foreground mb-2">
Anzahl Portionen E-Mail des Empfängers
</label> </label>
<input <input
id="portions" id="target-email"
type="number" type="email"
bind:value={createPortions} bind:value={createTargetEmail}
min="2" placeholder="empfaenger@example.com"
max="100"
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
disabled={creating} disabled={creating}
/> />
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">
Jede Person erhält {Math.floor(createCredits / createPortions)} Credits Wird automatisch eingelöst, wenn sich diese Person registriert.
</p> </p>
</div> </div>
{/if} {/if}
{#if createType === 'riddle'}
<div>
<label for="riddle-question" class="block text-sm font-medium text-foreground mb-2">
Rätsel-Frage
</label>
<input
id="riddle-question"
type="text"
bind:value={createRiddleQuestion}
placeholder="z.B. Was ist die Hauptstadt von Deutschland?"
maxlength="200"
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
disabled={creating}
/>
</div>
<div>
<label for="riddle-answer" class="block text-sm font-medium text-foreground mb-2">
Antwort
</label>
<input
id="riddle-answer"
type="text"
bind:value={createRiddleAnswer}
placeholder="z.B. Berlin"
maxlength="100"
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
disabled={creating}
/>
</div>
{/if}
<div> <div>
<label for="message" class="block text-sm font-medium text-foreground mb-2"> <label for="message" class="block text-sm font-medium text-foreground mb-2">
Nachricht (optional) Nachricht (optional)

View file

@ -126,7 +126,7 @@
<p class="font-medium text-green-800 dark:text-green-200">Gültiger Code gefunden!</p> <p class="font-medium text-green-800 dark:text-green-200">Gültiger Code gefunden!</p>
</div> </div>
<div class="text-sm text-green-700 dark:text-green-300 space-y-1"> <div class="text-sm text-green-700 dark:text-green-300 space-y-1">
<p>Credits: <span class="font-semibold">{giftInfo.creditsPerPortion}</span></p> <p>Credits: <span class="font-semibold">{giftInfo.totalCredits}</span></p>
<p>Status: <span class="font-semibold">{getStatusLabel(giftInfo.status)}</span></p> <p>Status: <span class="font-semibold">{getStatusLabel(giftInfo.status)}</span></p>
{#if giftInfo.creatorName} {#if giftInfo.creatorName}
<p>Von: <span class="font-semibold">{giftInfo.creatorName}</span></p> <p>Von: <span class="font-semibold">{giftInfo.creatorName}</span></p>

View file

@ -11,7 +11,6 @@
let redeeming = $state(false); let redeeming = $state(false);
let success = $state(false); let success = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let riddleAnswer = $state('');
let receivedCredits = $state(0); let receivedCredits = $state(0);
let newBalance = $state(0); let newBalance = $state(0);
@ -39,19 +38,12 @@
async function handleRedeem() { async function handleRedeem() {
if (!giftInfo) return; if (!giftInfo) return;
if (giftInfo.hasRiddle && !riddleAnswer.trim()) {
showToast('Bitte gib die Antwort auf das Rätsel ein', 'error');
return;
}
redeeming = true; redeeming = true;
error = null; error = null;
try { try {
const result = await giftsService.redeemGift( const result = await giftsService.redeemGift(code);
code,
giftInfo.hasRiddle ? riddleAnswer : undefined
);
if (result.success) { if (result.success) {
success = true; success = true;
@ -110,12 +102,6 @@
return 'Geschenk'; return 'Geschenk';
case 'personalized': case 'personalized':
return 'Persönliches Geschenk'; return 'Persönliches Geschenk';
case 'split':
return 'Geteiltes Geschenk';
case 'first_come':
return 'Erste kommen';
case 'riddle':
return 'Rätsel-Geschenk';
default: default:
return type; return type;
} }
@ -198,7 +184,7 @@
<div class="mt-6 text-center"> <div class="mt-6 text-center">
<p class="text-sm text-muted-foreground">Du erhältst</p> <p class="text-sm text-muted-foreground">Du erhältst</p>
<p class="text-4xl font-bold text-primary">{giftInfo.creditsPerPortion}</p> <p class="text-4xl font-bold text-primary">{giftInfo.totalCredits}</p>
<p class="text-muted-foreground">Credits</p> <p class="text-muted-foreground">Credits</p>
</div> </div>
@ -217,14 +203,6 @@
{getStatusLabel(giftInfo.status)} {getStatusLabel(giftInfo.status)}
</span> </span>
</div> </div>
{#if giftInfo.totalPortions > 1}
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Verfügbar</span>
<span class="font-medium"
>{giftInfo.remainingPortions} / {giftInfo.totalPortions}</span
>
</div>
{/if}
{#if giftInfo.expiresAt} {#if giftInfo.expiresAt}
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-muted-foreground">Gültig bis</span> <span class="text-muted-foreground">Gültig bis</span>
@ -249,7 +227,7 @@
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-4 text-center"> <div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-4 text-center">
<p class="font-medium text-amber-800 dark:text-amber-200"> <p class="font-medium text-amber-800 dark:text-amber-200">
{#if giftInfo.status === 'depleted'} {#if giftInfo.status === 'depleted'}
Dieses Geschenk wurde bereits vollständig eingelöst Dieses Geschenk wurde bereits eingelöst
{:else if giftInfo.status === 'expired'} {:else if giftInfo.status === 'expired'}
Dieses Geschenk ist abgelaufen Dieses Geschenk ist abgelaufen
{:else} {:else}
@ -270,32 +248,6 @@
</div> </div>
{/if} {/if}
{#if giftInfo.hasRiddle}
<div class="mb-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-4">
<div class="flex items-start gap-2">
<span class="text-xl">🧩</span>
<div>
<p class="font-medium text-purple-800 dark:text-purple-200 mb-2">Rätsel:</p>
<p class="text-purple-900 dark:text-purple-100">{giftInfo.riddleQuestion}</p>
</div>
</div>
</div>
<div class="mb-6">
<label for="riddle-answer" class="block text-sm font-medium text-foreground mb-2">
Deine Antwort
</label>
<input
id="riddle-answer"
type="text"
bind:value={riddleAnswer}
placeholder="Antwort eingeben..."
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
disabled={redeeming}
/>
</div>
{/if}
{#if error} {#if error}
<div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4"> <div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-800 dark:text-red-200">{error}</p> <p class="text-sm text-red-800 dark:text-red-200">{error}</p>
@ -304,7 +256,7 @@
<button <button
onclick={handleRedeem} onclick={handleRedeem}
disabled={redeeming || (giftInfo.hasRiddle && !riddleAnswer.trim())} disabled={redeeming}
class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 flex items-center justify-center gap-2" class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 flex items-center justify-center gap-2"
> >
{#if redeeming} {#if redeeming}

View file

@ -28,7 +28,6 @@
} }
function handleRedeem() { function handleRedeem() {
// Redirect to the redemption page (within the authenticated area)
goto(`/gifts/redeem/${code}`); goto(`/gifts/redeem/${code}`);
} }
@ -63,12 +62,6 @@
return 'Geschenk'; return 'Geschenk';
case 'personalized': case 'personalized':
return 'Persönliches Geschenk'; return 'Persönliches Geschenk';
case 'split':
return 'Geteiltes Geschenk';
case 'first_come':
return 'Erste kommen';
case 'riddle':
return 'Rätsel-Geschenk';
default: default:
return type; return type;
} }
@ -130,7 +123,7 @@
<div class="mb-6 rounded-lg bg-amber-50 dark:bg-amber-900/20 p-4 text-center"> <div class="mb-6 rounded-lg bg-amber-50 dark:bg-amber-900/20 p-4 text-center">
<p class="font-medium text-amber-800 dark:text-amber-200"> <p class="font-medium text-amber-800 dark:text-amber-200">
{#if giftInfo.status === 'depleted'} {#if giftInfo.status === 'depleted'}
Dieses Geschenk wurde bereits vollständig eingelöst Dieses Geschenk wurde bereits eingelöst
{:else if giftInfo.status === 'expired'} {:else if giftInfo.status === 'expired'}
Dieses Geschenk ist abgelaufen Dieses Geschenk ist abgelaufen
{:else} {:else}
@ -143,7 +136,7 @@
<!-- Credits amount --> <!-- Credits amount -->
<div class="mb-6 text-center"> <div class="mb-6 text-center">
<p class="text-sm text-muted-foreground">Du erhältst</p> <p class="text-sm text-muted-foreground">Du erhältst</p>
<p class="text-5xl font-bold text-primary">{giftInfo.creditsPerPortion}</p> <p class="text-5xl font-bold text-primary">{giftInfo.totalCredits}</p>
<p class="text-lg text-muted-foreground">Credits</p> <p class="text-lg text-muted-foreground">Credits</p>
</div> </div>
@ -153,14 +146,6 @@
<span class="text-muted-foreground">Art</span> <span class="text-muted-foreground">Art</span>
<span class="font-medium">{getTypeLabel(giftInfo.type)}</span> <span class="font-medium">{getTypeLabel(giftInfo.type)}</span>
</div> </div>
{#if giftInfo.totalPortions > 1}
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">Verfügbar</span>
<span class="font-medium"
>{giftInfo.remainingPortions} / {giftInfo.totalPortions}</span
>
</div>
{/if}
{#if giftInfo.expiresAt} {#if giftInfo.expiresAt}
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-muted-foreground">Gültig bis</span> <span class="text-muted-foreground">Gültig bis</span>
@ -177,16 +162,6 @@
</div> </div>
{/if} {/if}
<!-- Riddle hint -->
{#if giftInfo.hasRiddle}
<div class="mb-6 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-4 text-center">
<span class="text-2xl">🧩</span>
<p class="mt-1 text-sm text-purple-800 dark:text-purple-200">
Dieses Geschenk enthält ein Rätsel
</p>
</div>
{/if}
<!-- Personalized hint --> <!-- Personalized hint -->
{#if giftInfo.isPersonalized && giftInfo.status === 'active'} {#if giftInfo.isPersonalized && giftInfo.status === 'active'}
<div class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-center"> <div class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 text-center">

View file

@ -23,7 +23,6 @@ export {
getOperationsByCategory, getOperationsByCategory,
calculateBulkCost, calculateBulkCost,
isFreeOperation, isFreeOperation,
isMicroCreditOperation,
isAiOperation, isAiOperation,
formatCreditCost, formatCreditCost,
getPricingTable, getPricingTable,

View file

@ -58,28 +58,6 @@ export enum CreditOperationType {
AI_SUGGESTIONS = 'ai_suggestions', AI_SUGGESTIONS = 'ai_suggestions',
AI_ENRICHMENT = 'ai_enrichment', AI_ENRICHMENT = 'ai_enrichment',
// -------------------------------------------------------------------------
// Productivity Operations (Micro Credits: 0.01-0.10)
// -------------------------------------------------------------------------
// Todo
TASK_CREATE = 'task_create',
PROJECT_CREATE = 'project_create',
// Calendar
EVENT_CREATE = 'event_create',
CALENDAR_CREATE = 'calendar_create',
// Contacts
CONTACT_CREATE = 'contact_create',
// Zitare
COLLECTION_CREATE = 'collection_create',
// Presi
PRESENTATION_CREATE = 'presentation_create',
SLIDE_CREATE = 'slide_create',
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Premium Features (Standard Credits: 0.5-5) // Premium Features (Standard Credits: 0.5-5)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -134,20 +112,6 @@ export const CREDIT_COSTS: Record<CreditOperationType, number> = {
[CreditOperationType.AI_SUGGESTIONS]: 2, [CreditOperationType.AI_SUGGESTIONS]: 2,
[CreditOperationType.AI_ENRICHMENT]: 2, [CreditOperationType.AI_ENRICHMENT]: 2,
// Productivity Operations (Micro Credits)
[CreditOperationType.TASK_CREATE]: 0.02,
[CreditOperationType.PROJECT_CREATE]: 0.1,
[CreditOperationType.EVENT_CREATE]: 0.02,
[CreditOperationType.CALENDAR_CREATE]: 0.1,
[CreditOperationType.CONTACT_CREATE]: 0.02,
[CreditOperationType.COLLECTION_CREATE]: 0.1,
[CreditOperationType.PRESENTATION_CREATE]: 0.5,
[CreditOperationType.SLIDE_CREATE]: 0.02,
// Premium Features // Premium Features
[CreditOperationType.CALDAV_SYNC]: 0.5, [CreditOperationType.CALDAV_SYNC]: 0.5,
[CreditOperationType.GOOGLE_SYNC]: 0.5, [CreditOperationType.GOOGLE_SYNC]: 0.5,
@ -328,64 +292,6 @@ export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata>
app: 'contacts', app: 'contacts',
}, },
// Productivity - Todo
[CreditOperationType.TASK_CREATE]: {
name: 'Create Task',
description: 'Create a new task',
category: CreditCategory.PRODUCTIVITY,
app: 'todo',
},
[CreditOperationType.PROJECT_CREATE]: {
name: 'Create Project',
description: 'Create a new project',
category: CreditCategory.PRODUCTIVITY,
app: 'todo',
},
// Productivity - Calendar
[CreditOperationType.EVENT_CREATE]: {
name: 'Create Event',
description: 'Create a calendar event',
category: CreditCategory.PRODUCTIVITY,
app: 'calendar',
},
[CreditOperationType.CALENDAR_CREATE]: {
name: 'Create Calendar',
description: 'Create a new calendar',
category: CreditCategory.PRODUCTIVITY,
app: 'calendar',
},
// Productivity - Contacts
[CreditOperationType.CONTACT_CREATE]: {
name: 'Create Contact',
description: 'Create a new contact',
category: CreditCategory.PRODUCTIVITY,
app: 'contacts',
},
// Productivity - Zitare
[CreditOperationType.COLLECTION_CREATE]: {
name: 'Create Collection',
description: 'Create a quote collection',
category: CreditCategory.PRODUCTIVITY,
app: 'zitare',
},
// Productivity - Presi
[CreditOperationType.PRESENTATION_CREATE]: {
name: 'Create Presentation',
description: 'Create a new presentation',
category: CreditCategory.PRODUCTIVITY,
app: 'presi',
},
[CreditOperationType.SLIDE_CREATE]: {
name: 'Create Slide',
description: 'Add a slide to a presentation',
category: CreditCategory.PRODUCTIVITY,
app: 'presi',
},
// Premium - Sync // Premium - Sync
[CreditOperationType.CALDAV_SYNC]: { [CreditOperationType.CALDAV_SYNC]: {
name: 'CalDAV Sync', name: 'CalDAV Sync',
@ -502,16 +408,6 @@ export function isFreeOperation(operation: CreditOperationType): boolean {
return CREDIT_COSTS[operation] === 0; return CREDIT_COSTS[operation] === 0;
} }
/**
* Check if an operation is a micro-credit operation (< 0.5 credits).
* @param operation The operation type
* @returns True if micro-credit operation
*/
export function isMicroCreditOperation(operation: CreditOperationType): boolean {
const cost = CREDIT_COSTS[operation];
return cost > 0 && cost < 0.5;
}
/** /**
* Check if an operation is an AI operation. * Check if an operation is an AI operation.
* @param operation The operation type * @param operation The operation type

View file

@ -1,9 +1,5 @@
<script lang="ts"> <script lang="ts">
import { import { getPricingTable, CreditCategory, type CreditOperationType } from './operations';
getPricingTable,
CreditCategory,
type CreditOperationType,
} from './operations';
interface Props { interface Props {
/** The app to show pricing for (e.g., 'todo', 'chat', 'calendar') */ /** The app to show pricing for (e.g., 'todo', 'chat', 'calendar') */
@ -19,7 +15,6 @@
costLabel?: string; costLabel?: string;
freeLabel?: string; freeLabel?: string;
aiLabel?: string; aiLabel?: string;
productivityLabel?: string;
premiumLabel?: string; premiumLabel?: string;
creditsLabel?: string; creditsLabel?: string;
} }
@ -33,7 +28,6 @@
costLabel = 'Cost', costLabel = 'Cost',
freeLabel = 'Free', freeLabel = 'Free',
aiLabel = 'AI Features', aiLabel = 'AI Features',
productivityLabel = 'Create',
premiumLabel = 'Premium', premiumLabel = 'Premium',
creditsLabel = 'Credits', creditsLabel = 'Credits',
}: Props = $props(); }: Props = $props();
@ -62,8 +56,6 @@
switch (category) { switch (category) {
case CreditCategory.AI: case CreditCategory.AI:
return aiLabel; return aiLabel;
case CreditCategory.PRODUCTIVITY:
return productivityLabel;
case CreditCategory.PREMIUM: case CreditCategory.PREMIUM:
return premiumLabel; return premiumLabel;
default: default:
@ -75,8 +67,6 @@
switch (category) { switch (category) {
case CreditCategory.AI: case CreditCategory.AI:
return 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z'; return 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z';
case CreditCategory.PRODUCTIVITY:
return 'M12 4.5v15m7.5-7.5h-15';
case CreditCategory.PREMIUM: case CreditCategory.PREMIUM:
return 'M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z'; return 'M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z';
default: default:

View file

@ -11,6 +11,7 @@
], ],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"prepare": "tsc",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",

View file

@ -85,8 +85,7 @@ export async function consumeCredits(
operation: string, operation: string,
amount: number, amount: number,
description: string, description: string,
metadata?: Record<string, unknown>, metadata?: Record<string, unknown>
creditSource?: { type: 'guild'; guildId: string }
): Promise<boolean> { ): Promise<boolean> {
const result = await callCredits('/api/v1/internal/credits/use', { const result = await callCredits('/api/v1/internal/credits/use', {
method: 'POST', method: 'POST',
@ -96,7 +95,6 @@ export async function consumeCredits(
appId: APP_ID(), appId: APP_ID(),
description, description,
metadata: { operation, ...metadata }, metadata: { operation, ...metadata },
...(creditSource && { creditSource }),
}), }),
}); });
return !!result; return !!result;

View file

@ -24,7 +24,8 @@ export type DragType =
| 'note' | 'note'
| 'transaction' | 'transaction'
| 'place' | 'place'
| 'dream'; | 'dream'
| 'journal-entry';
export interface DragPayload<T = Record<string, unknown>> { export interface DragPayload<T = Record<string, unknown>> {
type: DragType; type: DragType;

View file

@ -32,24 +32,13 @@ bun run db:studio # Open Drizzle Studio
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/v1/credits/balance` | Get personal balance | | GET | `/api/v1/credits/balance` | Get personal balance |
| POST | `/api/v1/credits/use` | Use credits (personal or guild) | | POST | `/api/v1/credits/use` | Use credits |
| GET | `/api/v1/credits/transactions` | Transaction history | | GET | `/api/v1/credits/transactions` | Transaction history |
| GET | `/api/v1/credits/purchases` | Purchase history | | GET | `/api/v1/credits/purchases` | Purchase history |
| GET | `/api/v1/credits/packages` | Available packages | | GET | `/api/v1/credits/packages` | Available packages |
| POST | `/api/v1/credits/purchase` | Initiate Stripe purchase | | POST | `/api/v1/credits/purchase` | Initiate Stripe purchase |
| GET | `/api/v1/credits/purchase/:id` | Purchase status | | GET | `/api/v1/credits/purchase/:id` | Purchase status |
### Guild Pool (JWT auth)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/credits/guild/:id/balance` | Pool balance |
| POST | `/api/v1/credits/guild/:id/fund` | Fund pool from personal |
| POST | `/api/v1/credits/guild/:id/use` | Use from pool |
| GET | `/api/v1/credits/guild/:id/transactions` | Pool history |
| GET | `/api/v1/credits/guild/:id/members/:uid/limits` | Get limits |
| PUT | `/api/v1/credits/guild/:id/members/:uid/limits` | Set limits |
### Gift Codes (Mixed auth) ### Gift Codes (Mixed auth)
| Method | Path | Description | | Method | Path | Description |
@ -70,7 +59,6 @@ bun run db:studio # Open Drizzle Studio
| POST | `/api/v1/internal/credits/refund` | Refund credits | | POST | `/api/v1/internal/credits/refund` | Refund credits |
| POST | `/api/v1/internal/credits/init` | Initialize balance | | POST | `/api/v1/internal/credits/init` | Initialize balance |
| POST | `/api/v1/internal/gifts/redeem-pending` | Auto-redeem on registration | | POST | `/api/v1/internal/gifts/redeem-pending` | Auto-redeem on registration |
| POST | `/api/v1/internal/guild-pool/init` | Initialize guild pool |
### Webhooks ### Webhooks
@ -97,4 +85,16 @@ Own database: `mana_credits`
Schemas: `credits.*`, `gifts.*` Schemas: `credits.*`, `gifts.*`
Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, gift_codes, gift_redemptions, guild_pools, guild_transactions, guild_spending_limits Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, gift_codes, gift_redemptions
## Credit Operations
Credits are only charged for operations that cost real money:
- **AI operations** (2-25 credits): Chat with GPT-4/Claude/Gemini, image generation, research, food/plant analysis
- **Premium features** (0.5-5 credits): CalDAV/Google sync, cloud sync, PDF export, bulk import
Local-first CRUD operations (tasks, events, contacts, etc.) are **free** — they happen in IndexedDB with no server cost.
## Gift Types
Two gift types: `simple` (anyone with code can redeem) and `personalized` (auto-redeemed when target email registers). Each gift is single-use.

View file

@ -26,7 +26,6 @@ export const transactionTypeEnum = pgEnum('transaction_type', [
'usage', 'usage',
'refund', 'refund',
'gift', 'gift',
'guild_funding',
]); ]);
export const transactionStatusEnum = pgEnum('transaction_status', [ export const transactionStatusEnum = pgEnum('transaction_status', [
@ -73,7 +72,6 @@ export const transactions = creditsSchema.table(
description: text('description').notNull(), description: text('description').notNull(),
metadata: jsonb('metadata'), metadata: jsonb('metadata'),
idempotencyKey: text('idempotency_key').unique(), idempotencyKey: text('idempotency_key').unique(),
guildId: text('guild_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }), completedAt: timestamp('completed_at', { withTimezone: true }),
}, },
@ -82,7 +80,6 @@ export const transactions = creditsSchema.table(
appIdIdx: index('transactions_app_id_idx').on(table.appId), appIdIdx: index('transactions_app_id_idx').on(table.appId),
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt), createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey), idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
guildIdIdx: index('transactions_guild_id_idx').on(table.guildId),
}) })
); );

View file

@ -11,13 +11,7 @@ export const giftsSchema = pgSchema('gifts');
// ─── Enums ────────────────────────────────────────────────── // ─── Enums ──────────────────────────────────────────────────
export const giftCodeTypeEnum = pgEnum('gift_code_type', [ export const giftCodeTypeEnum = pgEnum('gift_code_type', ['simple', 'personalized']);
'simple',
'personalized',
'split',
'first_come',
'riddle',
]);
export const giftCodeStatusEnum = pgEnum('gift_code_status', [ export const giftCodeStatusEnum = pgEnum('gift_code_status', [
'active', 'active',
@ -29,7 +23,6 @@ export const giftCodeStatusEnum = pgEnum('gift_code_status', [
export const giftRedemptionStatusEnum = pgEnum('gift_redemption_status', [ export const giftRedemptionStatusEnum = pgEnum('gift_redemption_status', [
'success', 'success',
'failed_wrong_answer',
'failed_wrong_user', 'failed_wrong_user',
'failed_depleted', 'failed_depleted',
'failed_expired', 'failed_expired',
@ -51,9 +44,7 @@ export const giftCodes = giftsSchema.table(
// Credit allocation // Credit allocation
totalCredits: integer('total_credits').notNull(), totalCredits: integer('total_credits').notNull(),
creditsPerPortion: integer('credits_per_portion').notNull(), redeemed: integer('redeemed').notNull().default(0), // 0 = unclaimed, 1 = claimed
totalPortions: integer('total_portions').notNull().default(1),
claimedPortions: integer('claimed_portions').notNull().default(0),
// Type and status // Type and status
type: giftCodeTypeEnum('type').notNull().default('simple'), type: giftCodeTypeEnum('type').notNull().default('simple'),
@ -62,10 +53,6 @@ export const giftCodes = giftsSchema.table(
// Personalization // Personalization
targetEmail: text('target_email'), targetEmail: text('target_email'),
// Riddle
riddleQuestion: text('riddle_question'),
riddleAnswerHash: text('riddle_answer_hash'),
// Message // Message
message: text('message'), message: text('message'),
@ -98,7 +85,6 @@ export const giftRedemptions = giftsSchema.table(
status: giftRedemptionStatusEnum('status').notNull(), status: giftRedemptionStatusEnum('status').notNull(),
creditsReceived: integer('credits_received').notNull().default(0), creditsReceived: integer('credits_received').notNull().default(0),
portionNumber: integer('portion_number'),
creditTransactionId: uuid('credit_transaction_id'), creditTransactionId: uuid('credit_transaction_id'),
sourceAppId: text('source_app_id'), sourceAppId: text('source_app_id'),
@ -127,8 +113,6 @@ export const GIFT_CODE_LENGTH = 6;
export const GIFT_CODE_RULES = { export const GIFT_CODE_RULES = {
minCredits: 1, minCredits: 1,
maxCredits: 10000, maxCredits: 10000,
maxPortions: 100,
maxMessageLength: 500, maxMessageLength: 500,
maxRiddleQuestionLength: 200,
defaultExpirationDays: 90, defaultExpirationDays: 90,
} as const; } as const;

View file

@ -1,79 +0,0 @@
/**
* Guild Pool Schema Shared Mana pools for organizations
*
* Adapted from mana-auth: removed FK references to auth.users and organizations.
* Organization/user IDs remain as text columns without FK constraints.
*/
import { uuid, integer, text, timestamp, jsonb, index, unique } from 'drizzle-orm/pg-core';
import { creditsSchema } from './credits';
/** Guild Mana pool (one per organization) */
export const guildPools = creditsSchema.table('guild_pools', {
organizationId: text('organization_id').primaryKey(),
balance: integer('balance').default(0).notNull(),
totalFunded: integer('total_funded').default(0).notNull(),
totalSpent: integer('total_spent').default(0).notNull(),
version: integer('version').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
/** Optional per-member spending limits */
export const guildSpendingLimits = creditsSchema.table(
'guild_spending_limits',
{
id: uuid('id').primaryKey().defaultRandom(),
organizationId: text('organization_id').notNull(),
userId: text('user_id').notNull(),
dailyLimit: integer('daily_limit'),
monthlyLimit: integer('monthly_limit'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
orgUserUnique: unique('guild_spending_limits_org_user_unique').on(
table.organizationId,
table.userId
),
organizationIdIdx: index('guild_spending_limits_org_id_idx').on(table.organizationId),
userIdIdx: index('guild_spending_limits_user_id_idx').on(table.userId),
})
);
/** Immutable transaction ledger for guild pool */
export const guildTransactions = creditsSchema.table(
'guild_transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
organizationId: text('organization_id').notNull(),
userId: text('user_id').notNull(),
type: text('type').notNull(), // 'funding', 'usage', 'refund'
amount: integer('amount').notNull(),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id'),
description: text('description').notNull(),
metadata: jsonb('metadata'),
idempotencyKey: text('idempotency_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(table) => ({
organizationIdIdx: index('guild_transactions_org_id_idx').on(table.organizationId),
userIdIdx: index('guild_transactions_user_id_idx').on(table.userId),
createdAtIdx: index('guild_transactions_created_at_idx').on(table.createdAt),
idempotencyKeyIdx: index('guild_transactions_idempotency_key_idx').on(table.idempotencyKey),
orgUserCreatedIdx: index('guild_transactions_org_user_created_idx').on(
table.organizationId,
table.userId,
table.createdAt
),
})
);
// ─── Type Exports ───────────────────────────────────────────
export type GuildPool = typeof guildPools.$inferSelect;
export type GuildTransaction = typeof guildTransactions.$inferSelect;
export type GuildSpendingLimit = typeof guildSpendingLimits.$inferSelect;

View file

@ -1,3 +1,2 @@
export * from './credits'; export * from './credits';
export * from './gifts'; export * from './gifts';
export * from './guilds';

View file

@ -2,7 +2,7 @@
* mana-credits Standalone credit management service * mana-credits Standalone credit management service
* *
* Hono + Bun runtime. Extracted from mana-auth. * Hono + Bun runtime. Extracted from mana-auth.
* Handles: personal credits, guild pools, gift codes, Stripe payments. * Handles: personal credits, gift codes, Stripe payments.
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
@ -13,12 +13,10 @@ import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
import { jwtAuth } from './middleware/jwt-auth'; import { jwtAuth } from './middleware/jwt-auth';
import { serviceAuth } from './middleware/service-auth'; import { serviceAuth } from './middleware/service-auth';
import { StripeService } from './services/stripe'; import { StripeService } from './services/stripe';
import { GuildPoolService } from './services/guild-pool';
import { CreditsService } from './services/credits'; import { CreditsService } from './services/credits';
import { GiftCodeService } from './services/gift-code'; import { GiftCodeService } from './services/gift-code';
import { healthRoutes } from './routes/health'; import { healthRoutes } from './routes/health';
import { createCreditsRoutes } from './routes/credits'; import { createCreditsRoutes } from './routes/credits';
import { createGuildRoutes } from './routes/guild';
import { createGiftRoutes } from './routes/gifts'; import { createGiftRoutes } from './routes/gifts';
import { createInternalRoutes } from './routes/internal'; import { createInternalRoutes } from './routes/internal';
import { createWebhookRoutes } from './routes/stripe-webhook'; import { createWebhookRoutes } from './routes/stripe-webhook';
@ -30,8 +28,7 @@ const db = getDb(config.databaseUrl);
// Instantiate services (manual DI — no NestJS) // Instantiate services (manual DI — no NestJS)
const stripeService = new StripeService(db, config.stripe.secretKey); const stripeService = new StripeService(db, config.stripe.secretKey);
const guildPoolService = new GuildPoolService(db, config.manaAuthUrl, config.serviceKey); const creditsService = new CreditsService(db, stripeService);
const creditsService = new CreditsService(db, stripeService, guildPoolService);
const giftCodeService = new GiftCodeService(db, config.baseUrl); const giftCodeService = new GiftCodeService(db, config.baseUrl);
// ─── App ──────────────────────────────────────────────────── // ─── App ────────────────────────────────────────────────────
@ -54,17 +51,13 @@ app.route('/health', healthRoutes);
// User-facing routes (JWT auth) // User-facing routes (JWT auth)
app.use('/api/v1/credits/*', jwtAuth(config.manaAuthUrl)); app.use('/api/v1/credits/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/credits', createCreditsRoutes(creditsService)); app.route('/api/v1/credits', createCreditsRoutes(creditsService));
app.route('/api/v1/credits/guild', createGuildRoutes(guildPoolService));
// Gift routes (mixed: public GET /:code, JWT for rest) // Gift routes (mixed: public GET /:code, JWT for rest)
app.route('/api/v1/gifts', createGiftRoutes(giftCodeService, config.manaAuthUrl)); app.route('/api/v1/gifts', createGiftRoutes(giftCodeService, config.manaAuthUrl));
// Service-to-service routes (X-Service-Key auth) // Service-to-service routes (X-Service-Key auth)
app.use('/api/v1/internal/*', serviceAuth(config.serviceKey)); app.use('/api/v1/internal/*', serviceAuth(config.serviceKey));
app.route( app.route('/api/v1/internal', createInternalRoutes(creditsService, giftCodeService));
'/api/v1/internal',
createInternalRoutes(creditsService, giftCodeService, guildPoolService)
);
// Stripe webhooks (verified by signature, no auth middleware) // Stripe webhooks (verified by signature, no auth middleware)
app.route( app.route(

View file

@ -10,12 +10,6 @@ export const useCreditsSchema = z.object({
amount: z.number().positive(), amount: z.number().positive(),
appId: z.string().min(1), appId: z.string().min(1),
description: z.string().min(1), description: z.string().min(1),
creditSource: z
.object({
type: z.literal('guild'),
guildId: z.string().min(1),
})
.optional(),
idempotencyKey: z.string().optional(), idempotencyKey: z.string().optional(),
metadata: z.record(z.unknown()).optional(), metadata: z.record(z.unknown()).optional(),
}); });
@ -29,33 +23,17 @@ export const createPaymentLinkSchema = z.object({
quantity: z.number().int().positive().default(1), quantity: z.number().int().positive().default(1),
}); });
// ─── Guild ──────────────────────────────────────────────────
export const fundGuildPoolSchema = z.object({
amount: z.number().positive(),
idempotencyKey: z.string().optional(),
});
export const setSpendingLimitSchema = z.object({
dailyLimit: z.number().int().positive().nullable().optional(),
monthlyLimit: z.number().int().positive().nullable().optional(),
});
// ─── Gifts ────────────────────────────────────────────────── // ─── Gifts ──────────────────────────────────────────────────
export const createGiftSchema = z.object({ export const createGiftSchema = z.object({
totalCredits: z.number().int().positive().min(1).max(10000), totalCredits: z.number().int().positive().min(1).max(10000),
type: z.enum(['simple', 'personalized', 'split', 'first_come', 'riddle']).default('simple'), type: z.enum(['simple', 'personalized']).default('simple'),
totalPortions: z.number().int().positive().max(100).default(1),
targetEmail: z.string().email().optional(), targetEmail: z.string().email().optional(),
riddleQuestion: z.string().max(200).optional(),
riddleAnswer: z.string().optional(),
message: z.string().max(500).optional(), message: z.string().max(500).optional(),
expirationDays: z.number().int().positive().optional(), expirationDays: z.number().int().positive().optional(),
}); });
export const redeemGiftSchema = z.object({ export const redeemGiftSchema = z.object({
riddleAnswer: z.string().optional(),
sourceAppId: z.string().optional(), sourceAppId: z.string().optional(),
}); });
@ -66,12 +44,6 @@ export const internalUseCreditsSchema = z.object({
amount: z.number().positive(), amount: z.number().positive(),
appId: z.string().min(1), appId: z.string().min(1),
description: z.string().min(1), description: z.string().min(1),
creditSource: z
.object({
type: z.literal('guild'),
guildId: z.string().min(1),
})
.optional(),
idempotencyKey: z.string().optional(), idempotencyKey: z.string().optional(),
metadata: z.record(z.unknown()).optional(), metadata: z.record(z.unknown()).optional(),
}); });

View file

@ -17,7 +17,7 @@ export function createCreditsRoutes(creditsService: CreditsService) {
.post('/use', async (c) => { .post('/use', async (c) => {
const user = c.get('user'); const user = c.get('user');
const body = useCreditsSchema.parse(await c.req.json()); const body = useCreditsSchema.parse(await c.req.json());
const result = await creditsService.useCreditsWithSource(user.userId, body); const result = await creditsService.useCredits(user.userId, body);
return c.json(result); return c.json(result);
}) })
.get('/transactions', async (c) => { .get('/transactions', async (c) => {

View file

@ -46,7 +46,6 @@ export function createGiftRoutes(giftCodeService: GiftCodeService, authUrl: stri
const result = await giftCodeService.redeemGift( const result = await giftCodeService.redeemGift(
c.req.param('code'), c.req.param('code'),
user.userId, user.userId,
body.riddleAnswer,
body.sourceAppId body.sourceAppId
); );
return c.json(result); return c.json(result);

View file

@ -1,69 +0,0 @@
/**
* Guild pool routes shared credit pool endpoints (JWT auth)
*/
import { Hono } from 'hono';
import type { GuildPoolService } from '../services/guild-pool';
import type { AuthUser } from '../middleware/jwt-auth';
import { fundGuildPoolSchema, setSpendingLimitSchema } from '../lib/validation';
export function createGuildRoutes(guildPoolService: GuildPoolService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/:guildId/balance', async (c) => {
const user = c.get('user');
const balance = await guildPoolService.getBalance(c.req.param('guildId'), user.userId);
return c.json(balance);
})
.post('/:guildId/fund', async (c) => {
const user = c.get('user');
const body = fundGuildPoolSchema.parse(await c.req.json());
const result = await guildPoolService.fundPool(
c.req.param('guildId'),
user.userId,
body.amount,
body.idempotencyKey
);
return c.json(result);
})
.post('/:guildId/use', async (c) => {
const user = c.get('user');
const body = await c.req.json();
const result = await guildPoolService.useGuildCredits(
c.req.param('guildId'),
user.userId,
body
);
return c.json(result);
})
.get('/:guildId/transactions', async (c) => {
const user = c.get('user');
const limit = parseInt(c.req.query('limit') || '50', 10);
const offset = parseInt(c.req.query('offset') || '0', 10);
const txs = await guildPoolService.getTransactions(
c.req.param('guildId'),
user.userId,
limit,
offset
);
return c.json(txs);
})
.get('/:guildId/members/:userId/limits', async (c) => {
const limits = await guildPoolService.getSpendingLimits(
c.req.param('guildId'),
c.req.param('userId')
);
return c.json(limits);
})
.put('/:guildId/members/:userId/limits', async (c) => {
const user = c.get('user');
const body = setSpendingLimitSchema.parse(await c.req.json());
const result = await guildPoolService.setSpendingLimits(
c.req.param('guildId'),
c.req.param('userId'),
user.userId,
body.dailyLimit ?? null,
body.monthlyLimit ?? null
);
return c.json(result);
});
}

View file

@ -5,7 +5,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { CreditsService } from '../services/credits'; import type { CreditsService } from '../services/credits';
import type { GiftCodeService } from '../services/gift-code'; import type { GiftCodeService } from '../services/gift-code';
import type { GuildPoolService } from '../services/guild-pool';
import { import {
internalUseCreditsSchema, internalUseCreditsSchema,
internalRefundSchema, internalRefundSchema,
@ -15,8 +14,7 @@ import {
export function createInternalRoutes( export function createInternalRoutes(
creditsService: CreditsService, creditsService: CreditsService,
giftCodeService: GiftCodeService, giftCodeService: GiftCodeService
guildPoolService: GuildPoolService
) { ) {
return new Hono() return new Hono()
.get('/credits/balance/:userId', async (c) => { .get('/credits/balance/:userId', async (c) => {
@ -26,7 +24,7 @@ export function createInternalRoutes(
.post('/credits/use', async (c) => { .post('/credits/use', async (c) => {
const body = internalUseCreditsSchema.parse(await c.req.json()); const body = internalUseCreditsSchema.parse(await c.req.json());
const { userId, ...params } = body; const { userId, ...params } = body;
const result = await creditsService.useCreditsWithSource(userId, params); const result = await creditsService.useCredits(userId, params);
return c.json(result); return c.json(result);
}) })
.post('/credits/refund', async (c) => { .post('/credits/refund', async (c) => {
@ -49,10 +47,5 @@ export function createInternalRoutes(
const body = internalRedeemPendingSchema.parse(await c.req.json()); const body = internalRedeemPendingSchema.parse(await c.req.json());
const result = await giftCodeService.redeemPendingForUser(body.userId, body.email); const result = await giftCodeService.redeemPendingForUser(body.userId, body.email);
return c.json(result); return c.json(result);
})
.post('/guild-pool/init', async (c) => {
const body = await c.req.json();
const pool = await guildPoolService.initializePool(body.organizationId);
return c.json(pool);
}); });
} }

View file

@ -9,7 +9,6 @@ import { eq, and, desc } from 'drizzle-orm';
import { balances, transactions, purchases, packages, usageStats } from '../db/schema/credits'; import { balances, transactions, purchases, packages, usageStats } from '../db/schema/credits';
import type { Database } from '../db/connection'; import type { Database } from '../db/connection';
import type { StripeService } from './stripe'; import type { StripeService } from './stripe';
import type { GuildPoolService } from './guild-pool';
import { import {
BadRequestError, BadRequestError,
NotFoundError, NotFoundError,
@ -21,7 +20,6 @@ interface UseCreditsParams {
amount: number; amount: number;
appId: string; appId: string;
description: string; description: string;
creditSource?: { type: 'guild'; guildId: string };
idempotencyKey?: string; idempotencyKey?: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
@ -29,8 +27,7 @@ interface UseCreditsParams {
export class CreditsService { export class CreditsService {
constructor( constructor(
private db: Database, private db: Database,
private stripeService: StripeService, private stripeService: StripeService
private guildPoolService: GuildPoolService
) {} ) {}
async initializeBalance(userId: string) { async initializeBalance(userId: string) {
@ -150,14 +147,6 @@ export class CreditsService {
}); });
} }
/** Route to personal or guild pool based on creditSource */
async useCreditsWithSource(userId: string, params: UseCreditsParams) {
if (params.creditSource?.type === 'guild' && params.creditSource.guildId) {
return this.guildPoolService.useGuildCredits(params.creditSource.guildId, userId, params);
}
return this.useCredits(userId, params);
}
async refundCredits( async refundCredits(
userId: string, userId: string,
amount: number, amount: number,

View file

@ -1,11 +1,11 @@
/** /**
* Gift Code Service Gift code generation, redemption, cancellation * Gift Code Service Gift code generation, redemption, cancellation
* *
* Ported from mana-auth GiftCodeService. * Simplified: only 'simple' and 'personalized' gift types.
* Each gift is a single-use code (one redeemer gets all credits).
*/ */
import { eq, and, desc } from 'drizzle-orm'; import { eq, and, desc } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { import {
giftCodes, giftCodes,
giftRedemptions, giftRedemptions,
@ -19,11 +19,8 @@ import { BadRequestError, NotFoundError } from '../lib/errors';
interface CreateGiftParams { interface CreateGiftParams {
totalCredits: number; totalCredits: number;
type?: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; type?: 'simple' | 'personalized';
totalPortions?: number;
targetEmail?: string; targetEmail?: string;
riddleQuestion?: string;
riddleAnswer?: string;
message?: string; message?: string;
expirationDays?: number; expirationDays?: number;
} }
@ -43,7 +40,7 @@ export class GiftCodeService {
} }
async createGift(creatorId: string, creatorName: string, params: CreateGiftParams) { async createGift(creatorId: string, creatorName: string, params: CreateGiftParams) {
const { totalCredits, type = 'simple', totalPortions = 1 } = params; const { totalCredits, type = 'simple' } = params;
if (totalCredits < GIFT_CODE_RULES.minCredits || totalCredits > GIFT_CODE_RULES.maxCredits) { if (totalCredits < GIFT_CODE_RULES.minCredits || totalCredits > GIFT_CODE_RULES.maxCredits) {
throw new BadRequestError( throw new BadRequestError(
@ -51,10 +48,6 @@ export class GiftCodeService {
); );
} }
const creditsPerPortion = Math.floor(totalCredits / totalPortions);
if (creditsPerPortion < 1) throw new BadRequestError('Credits per portion must be at least 1');
// Reserve credits from creator's balance
return await this.db.transaction(async (tx) => { return await this.db.transaction(async (tx) => {
const [balance] = await tx const [balance] = await tx
.select() .select()
@ -94,12 +87,6 @@ export class GiftCodeService {
}) })
.returning(); .returning();
// Hash riddle answer if present
let riddleAnswerHash: string | undefined;
if (params.riddleAnswer) {
riddleAnswerHash = await bcrypt.hash(params.riddleAnswer.toLowerCase().trim(), 10);
}
const code = this.generateCode(); const code = this.generateCode();
const expiresAt = params.expirationDays const expiresAt = params.expirationDays
? new Date(Date.now() + params.expirationDays * 24 * 60 * 60 * 1000) ? new Date(Date.now() + params.expirationDays * 24 * 60 * 60 * 1000)
@ -113,12 +100,8 @@ export class GiftCodeService {
creatorId, creatorId,
creatorName, creatorName,
totalCredits, totalCredits,
creditsPerPortion,
totalPortions,
type, type,
targetEmail: params.targetEmail, targetEmail: params.targetEmail,
riddleQuestion: params.riddleQuestion,
riddleAnswerHash,
message: params.message, message: params.message,
expiresAt, expiresAt,
reservationTransactionId: reservationTx.id, reservationTransactionId: reservationTx.id,
@ -129,7 +112,7 @@ export class GiftCodeService {
}); });
} }
async redeemGift(code: string, redeemerId: string, riddleAnswer?: string, sourceAppId?: string) { async redeemGift(code: string, redeemerId: string, sourceAppId?: string) {
return await this.db.transaction(async (tx) => { return await this.db.transaction(async (tx) => {
const [gift] = await tx const [gift] = await tx
.select() .select()
@ -142,32 +125,7 @@ export class GiftCodeService {
if (gift.status !== 'active') throw new BadRequestError(`Gift code is ${gift.status}`); if (gift.status !== 'active') throw new BadRequestError(`Gift code is ${gift.status}`);
if (gift.expiresAt && new Date() > gift.expiresAt) if (gift.expiresAt && new Date() > gift.expiresAt)
throw new BadRequestError('Gift code expired'); throw new BadRequestError('Gift code expired');
if (gift.claimedPortions >= gift.totalPortions) if (gift.redeemed >= 1) throw new BadRequestError('Gift already claimed');
throw new BadRequestError('Gift fully claimed');
// Personalization check
if (gift.type === 'personalized' && gift.targetEmail) {
// Caller must verify email matches — for now we allow all
}
// Riddle check
if (gift.type === 'riddle' && gift.riddleAnswerHash) {
if (!riddleAnswer) throw new BadRequestError('Riddle answer required');
const correct = await bcrypt.compare(
riddleAnswer.toLowerCase().trim(),
gift.riddleAnswerHash
);
if (!correct) {
await tx.insert(giftRedemptions).values({
giftCodeId: gift.id,
redeemerUserId: redeemerId,
status: 'failed_wrong_answer',
creditsReceived: 0,
sourceAppId,
});
throw new BadRequestError('Wrong riddle answer');
}
}
// Add credits to redeemer // Add credits to redeemer
const [balance] = await tx const [balance] = await tx
@ -178,14 +136,14 @@ export class GiftCodeService {
.limit(1); .limit(1);
const balanceBefore = balance?.balance ?? 0; const balanceBefore = balance?.balance ?? 0;
const newBalance = balanceBefore + gift.creditsPerPortion; const newBalance = balanceBefore + gift.totalCredits;
if (balance) { if (balance) {
await tx await tx
.update(balances) .update(balances)
.set({ .set({
balance: newBalance, balance: newBalance,
totalEarned: balance.totalEarned + gift.creditsPerPortion, totalEarned: balance.totalEarned + gift.totalCredits,
version: balance.version + 1, version: balance.version + 1,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@ -193,8 +151,8 @@ export class GiftCodeService {
} else { } else {
await tx.insert(balances).values({ await tx.insert(balances).values({
userId: redeemerId, userId: redeemerId,
balance: gift.creditsPerPortion, balance: gift.totalCredits,
totalEarned: gift.creditsPerPortion, totalEarned: gift.totalCredits,
totalSpent: 0, totalSpent: 0,
}); });
} }
@ -206,11 +164,11 @@ export class GiftCodeService {
userId: redeemerId, userId: redeemerId,
type: 'gift', type: 'gift',
status: 'completed', status: 'completed',
amount: gift.creditsPerPortion, amount: gift.totalCredits,
balanceBefore, balanceBefore,
balanceAfter: newBalance, balanceAfter: newBalance,
appId: 'gifts', appId: 'gifts',
description: `Gift redeemed: ${gift.creditsPerPortion} credits from ${gift.creatorName || 'someone'}`, description: `Gift redeemed: ${gift.totalCredits} credits from ${gift.creatorName || 'someone'}`,
completedAt: new Date(), completedAt: new Date(),
}) })
.returning(); .returning();
@ -220,23 +178,20 @@ export class GiftCodeService {
giftCodeId: gift.id, giftCodeId: gift.id,
redeemerUserId: redeemerId, redeemerUserId: redeemerId,
status: 'success', status: 'success',
creditsReceived: gift.creditsPerPortion, creditsReceived: gift.totalCredits,
portionNumber: gift.claimedPortions + 1,
creditTransactionId: creditTx.id, creditTransactionId: creditTx.id,
sourceAppId, sourceAppId,
}); });
// Update gift // Mark gift as depleted
const newClaimedPortions = gift.claimedPortions + 1;
const newStatus = newClaimedPortions >= gift.totalPortions ? 'depleted' : 'active';
await tx await tx
.update(giftCodes) .update(giftCodes)
.set({ claimedPortions: newClaimedPortions, status: newStatus, updatedAt: new Date() }) .set({ redeemed: 1, status: 'depleted', updatedAt: new Date() })
.where(eq(giftCodes.id, gift.id)); .where(eq(giftCodes.id, gift.id));
return { return {
success: true, success: true,
creditsReceived: gift.creditsPerPortion, creditsReceived: gift.totalCredits,
message: gift.message, message: gift.message,
creatorName: gift.creatorName, creatorName: gift.creatorName,
}; };
@ -256,10 +211,8 @@ export class GiftCodeService {
code: gift.code, code: gift.code,
type: gift.type, type: gift.type,
status: gift.status, status: gift.status,
creditsPerPortion: gift.creditsPerPortion, totalCredits: gift.totalCredits,
totalPortions: gift.totalPortions, redeemed: gift.redeemed > 0,
claimedPortions: gift.claimedPortions,
riddleQuestion: gift.riddleQuestion,
message: gift.message, message: gift.message,
creatorName: gift.creatorName, creatorName: gift.creatorName,
expiresAt: gift.expiresAt, expiresAt: gift.expiresAt,
@ -294,7 +247,8 @@ export class GiftCodeService {
if (!gift) throw new NotFoundError('Gift not found'); if (!gift) throw new NotFoundError('Gift not found');
if (gift.status !== 'active') throw new BadRequestError('Only active gifts can be cancelled'); if (gift.status !== 'active') throw new BadRequestError('Only active gifts can be cancelled');
const refundAmount = (gift.totalPortions - gift.claimedPortions) * gift.creditsPerPortion; // Only refund if not yet redeemed
const refundAmount = gift.redeemed === 0 ? gift.totalCredits : 0;
if (refundAmount > 0) { if (refundAmount > 0) {
const [balance] = await tx const [balance] = await tx
@ -354,7 +308,7 @@ export class GiftCodeService {
let totalRedeemed = 0; let totalRedeemed = 0;
for (const gift of pendingGifts) { for (const gift of pendingGifts) {
try { try {
const result = await this.redeemGift(gift.code, userId, undefined, 'auto-registration'); const result = await this.redeemGift(gift.code, userId, 'auto-registration');
totalRedeemed += result.creditsReceived; totalRedeemed += result.creditsReceived;
} catch { } catch {
// Skip failed redemptions // Skip failed redemptions

View file

@ -1,316 +0,0 @@
/**
* Guild Pool Service Shared organization credit pools
*
* Ported from mana-auth GuildPoolService.
* Membership checks via HTTP call to mana-auth (separate DB).
*/
import { eq, and, desc, gte, sql } from 'drizzle-orm';
import { guildPools, guildTransactions, guildSpendingLimits } from '../db/schema/guilds';
import type { Database } from '../db/connection';
import {
BadRequestError,
NotFoundError,
ForbiddenError,
InsufficientCreditsError,
} from '../lib/errors';
interface UseGuildCreditsParams {
amount: number;
appId: string;
description: string;
idempotencyKey?: string;
metadata?: Record<string, unknown>;
}
export class GuildPoolService {
constructor(
private db: Database,
private authUrl: string,
private serviceKey: string
) {}
/** Verify guild membership via mana-auth internal API */
private async verifyMembership(
guildId: string,
userId: string
): Promise<{ isMember: boolean; role: string }> {
try {
const res = await fetch(`${this.authUrl}/api/v1/internal/org/${guildId}/member/${userId}`, {
headers: { 'X-Service-Key': this.serviceKey },
});
if (!res.ok) return { isMember: false, role: '' };
return await res.json();
} catch {
return { isMember: false, role: '' };
}
}
async initializePool(organizationId: string) {
const [existing] = await this.db
.select()
.from(guildPools)
.where(eq(guildPools.organizationId, organizationId))
.limit(1);
if (existing) return existing;
const [pool] = await this.db
.insert(guildPools)
.values({ organizationId, balance: 0, totalFunded: 0, totalSpent: 0 })
.returning();
return pool;
}
async getBalance(guildId: string, userId: string) {
const membership = await this.verifyMembership(guildId, userId);
if (!membership.isMember) throw new ForbiddenError('Not a guild member');
const [pool] = await this.db
.select()
.from(guildPools)
.where(eq(guildPools.organizationId, guildId))
.limit(1);
if (!pool) throw new NotFoundError('Guild pool not found');
return { balance: pool.balance, totalFunded: pool.totalFunded, totalSpent: pool.totalSpent };
}
async useGuildCredits(guildId: string, userId: string, params: UseGuildCreditsParams) {
const membership = await this.verifyMembership(guildId, userId);
if (!membership.isMember) throw new ForbiddenError('Not a guild member');
// Check spending limits
await this.checkSpendingLimits(guildId, userId, params.amount);
// Idempotency
if (params.idempotencyKey) {
const [existing] = await this.db
.select()
.from(guildTransactions)
.where(eq(guildTransactions.idempotencyKey, params.idempotencyKey))
.limit(1);
if (existing) return { success: true, transaction: existing };
}
return await this.db.transaction(async (tx) => {
const [pool] = await tx
.select()
.from(guildPools)
.where(eq(guildPools.organizationId, guildId))
.for('update')
.limit(1);
if (!pool) throw new NotFoundError('Guild pool not found');
if (pool.balance < params.amount) {
throw new InsufficientCreditsError(params.amount, pool.balance);
}
const newBalance = pool.balance - params.amount;
await tx
.update(guildPools)
.set({
balance: newBalance,
totalSpent: pool.totalSpent + params.amount,
version: pool.version + 1,
updatedAt: new Date(),
})
.where(and(eq(guildPools.organizationId, guildId), eq(guildPools.version, pool.version)));
const [transaction] = await tx
.insert(guildTransactions)
.values({
organizationId: guildId,
userId,
type: 'usage',
amount: -params.amount,
balanceBefore: pool.balance,
balanceAfter: newBalance,
appId: params.appId,
description: params.description,
metadata: params.metadata,
idempotencyKey: params.idempotencyKey,
completedAt: new Date(),
})
.returning();
return { success: true, transaction, newBalance: { balance: newBalance } };
});
}
async fundPool(guildId: string, funderId: string, amount: number, idempotencyKey?: string) {
const membership = await this.verifyMembership(guildId, funderId);
if (!membership.isMember || !['owner', 'admin'].includes(membership.role)) {
throw new ForbiddenError('Only owners and admins can fund the pool');
}
return await this.db.transaction(async (tx) => {
const [pool] = await tx
.select()
.from(guildPools)
.where(eq(guildPools.organizationId, guildId))
.for('update')
.limit(1);
if (!pool) throw new NotFoundError('Guild pool not found');
const newBalance = pool.balance + amount;
await tx
.update(guildPools)
.set({
balance: newBalance,
totalFunded: pool.totalFunded + amount,
version: pool.version + 1,
updatedAt: new Date(),
})
.where(eq(guildPools.organizationId, guildId));
const [transaction] = await tx
.insert(guildTransactions)
.values({
organizationId: guildId,
userId: funderId,
type: 'funding',
amount,
balanceBefore: pool.balance,
balanceAfter: newBalance,
description: `Pool funded with ${amount} credits`,
idempotencyKey,
completedAt: new Date(),
})
.returning();
return { success: true, transaction, newBalance: { balance: newBalance } };
});
}
async getTransactions(guildId: string, userId: string, limit = 50, offset = 0) {
const membership = await this.verifyMembership(guildId, userId);
if (!membership.isMember) throw new ForbiddenError('Not a guild member');
return this.db
.select()
.from(guildTransactions)
.where(eq(guildTransactions.organizationId, guildId))
.orderBy(desc(guildTransactions.createdAt))
.limit(limit)
.offset(offset);
}
async getSpendingLimits(guildId: string, userId: string) {
const [limit] = await this.db
.select()
.from(guildSpendingLimits)
.where(
and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId))
)
.limit(1);
return limit || { dailyLimit: null, monthlyLimit: null };
}
async setSpendingLimits(
guildId: string,
targetUserId: string,
setterId: string,
dailyLimit: number | null,
monthlyLimit: number | null
) {
const membership = await this.verifyMembership(guildId, setterId);
if (!membership.isMember || !['owner', 'admin'].includes(membership.role)) {
throw new ForbiddenError('Only owners and admins can set spending limits');
}
const [existing] = await this.db
.select()
.from(guildSpendingLimits)
.where(
and(
eq(guildSpendingLimits.organizationId, guildId),
eq(guildSpendingLimits.userId, targetUserId)
)
)
.limit(1);
if (existing) {
await this.db
.update(guildSpendingLimits)
.set({ dailyLimit, monthlyLimit, updatedAt: new Date() })
.where(eq(guildSpendingLimits.id, existing.id));
} else {
await this.db.insert(guildSpendingLimits).values({
organizationId: guildId,
userId: targetUserId,
dailyLimit,
monthlyLimit,
});
}
return { dailyLimit, monthlyLimit };
}
private async checkSpendingLimits(guildId: string, userId: string, amount: number) {
const [limit] = await this.db
.select()
.from(guildSpendingLimits)
.where(
and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId))
)
.limit(1);
if (!limit) return; // No limits set
const now = new Date();
if (limit.dailyLimit !== null) {
const dayStart = new Date(now);
dayStart.setHours(0, 0, 0, 0);
const dailySpent = await this.db
.select({ total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)` })
.from(guildTransactions)
.where(
and(
eq(guildTransactions.organizationId, guildId),
eq(guildTransactions.userId, userId),
eq(guildTransactions.type, 'usage'),
gte(guildTransactions.createdAt, dayStart)
)
);
const spent = Number(dailySpent[0]?.total ?? 0);
if (spent + amount > limit.dailyLimit) {
throw new BadRequestError(
`Daily spending limit exceeded (${limit.dailyLimit} credits/day)`
);
}
}
if (limit.monthlyLimit !== null) {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthlySpent = await this.db
.select({ total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)` })
.from(guildTransactions)
.where(
and(
eq(guildTransactions.organizationId, guildId),
eq(guildTransactions.userId, userId),
eq(guildTransactions.type, 'usage'),
gte(guildTransactions.createdAt, monthStart)
)
);
const spent = Number(monthlySpent[0]?.total ?? 0);
if (spent + amount > limit.monthlyLimit) {
throw new BadRequestError(
`Monthly spending limit exceeded (${limit.monthlyLimit} credits/month)`
);
}
}
}
}