mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
29ad31c4ed
commit
e068335dd4
32 changed files with 143 additions and 922 deletions
|
|
@ -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>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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}`, {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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) ?? '',
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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[] = [];
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 ===
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ export {
|
||||||
getOperationsByCategory,
|
getOperationsByCategory,
|
||||||
calculateBulkCost,
|
calculateBulkCost,
|
||||||
isFreeOperation,
|
isFreeOperation,
|
||||||
isMicroCreditOperation,
|
|
||||||
isAiOperation,
|
isAiOperation,
|
||||||
formatCreditCost,
|
formatCreditCost,
|
||||||
getPricingTable,
|
getPricingTable,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
export * from './credits';
|
export * from './credits';
|
||||||
export * from './gifts';
|
export * from './gifts';
|
||||||
export * from './guilds';
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue