mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
chore: misc fixes, new services, lockfile cleanup
Assorted changes from recent sessions: - .gitignore: add mana-sync binary, Forgejo data - chat/web: add isSidebarMode to navigation store - clock/web: fix alarm page markup - contacts/mukke/presi/questions: add svelte.config.js aliases - context/web: add missing dependency - manacore/landing: update pricing page - manacore/web + todo/web: update mana dashboard pages - planta/web: fix dashboard layout - pnpm-lock.yaml: cleanup after backend removals - docs/APP_GAP_ANALYSIS.md: new gap analysis doc - services/mana-analytics: add Dockerfile - services/mana-subscriptions: new Go subscription service Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d02b0419d
commit
8ccf8ff818
36 changed files with 1691 additions and 3468 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -18,6 +18,9 @@ ios/
|
||||||
# Turbo
|
# Turbo
|
||||||
.turbo/
|
.turbo/
|
||||||
|
|
||||||
|
# MCP config (contains API keys)
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
@ -127,3 +130,4 @@ pip-delete-this-directory.txt
|
||||||
# ML Models (large files, downloaded on demand)
|
# ML Models (large files, downloaded on demand)
|
||||||
mlx_models/
|
mlx_models/
|
||||||
services/matrix-stt-bot/data/
|
services/matrix-stt-bot/data/
|
||||||
|
services/mana-sync/server
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||||
|
|
||||||
export const { isNavCollapsed } = createSimpleNavigationStores();
|
export const { isNavCollapsed } = createSimpleNavigationStores();
|
||||||
|
|
||||||
|
/** Whether the app is in sidebar mode (e.g., embedded in another app) */
|
||||||
|
export const isSidebarMode = writable(false);
|
||||||
|
|
|
||||||
|
|
@ -211,10 +211,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Alarms (Grid) -->
|
<!-- Custom Alarms (Grid) -->
|
||||||
{@const customAlarms = allAlarms.value.filter(
|
{#if allAlarms.value.filter((a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))).length > 0}
|
||||||
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
|
{@const customAlarms = allAlarms.value.filter(
|
||||||
)}
|
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
|
||||||
{#if customAlarms.length > 0}
|
)}
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||||
{$_('alarm.custom')}
|
{$_('alarm.custom')}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ const config = {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
out: 'build',
|
out: 'build',
|
||||||
}),
|
}),
|
||||||
|
prerender: {
|
||||||
|
handleHttpError: ({ path, referrer, message }) => {
|
||||||
|
// Ignore missing favicon during prerender
|
||||||
|
if (path === '/favicon.png') return;
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"@manacore/shared-theme": "workspace:*",
|
"@manacore/shared-theme": "workspace:*",
|
||||||
"@manacore/shared-theme-ui": "workspace:*",
|
"@manacore/shared-theme-ui": "workspace:*",
|
||||||
"@manacore/shared-ui": "workspace:*",
|
"@manacore/shared-ui": "workspace:*",
|
||||||
|
"@manacore/shared-utils": "workspace:*",
|
||||||
"svelte-i18n": "^4.0.1"
|
"svelte-i18n": "^4.0.1"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const { showOneTime = false } = Astro.props;
|
||||||
Jährlich
|
Jährlich
|
||||||
<span
|
<span
|
||||||
class="ml-2 text-xs bg-gradient-to-r from-green-500 to-emerald-500 text-white px-2.5 py-1 rounded-full font-semibold shadow-sm"
|
class="ml-2 text-xs bg-gradient-to-r from-green-500 to-emerald-500 text-white px-2.5 py-1 rounded-full font-semibold shadow-sm"
|
||||||
>2 Monate gratis</span
|
>20% Rabatt</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import { pricingPlans } from '../data/pricing.js';
|
||||||
<button class="toggle-btn active" data-type="monthly"> Monatlich </button>
|
<button class="toggle-btn active" data-type="monthly"> Monatlich </button>
|
||||||
<button class="toggle-btn" data-type="yearly">
|
<button class="toggle-btn" data-type="yearly">
|
||||||
Jährlich
|
Jährlich
|
||||||
<span class="badge">2 Monate gratis</span>
|
<span class="badge">20% Rabatt</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
pageTitle="Wähle dein Abo"
|
pageTitle="Wähle dein Abo"
|
||||||
subscriptionsTitle="Abonnements"
|
subscriptionsTitle="Abonnements"
|
||||||
packagesTitle="Einmal-Pakete"
|
packagesTitle="Einmal-Pakete"
|
||||||
yearlyDiscount="2 Monate gratis"
|
yearlyDiscount="20% Rabatt"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ const config = {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
out: 'build',
|
out: 'build',
|
||||||
}),
|
}),
|
||||||
|
prerender: {
|
||||||
|
handleHttpError: ({ path, referrer, message }) => {
|
||||||
|
// Ignore missing favicon during prerender
|
||||||
|
if (path === '/favicon.png') return;
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,10 @@
|
||||||
{#if plant.commonName}
|
{#if plant.commonName}
|
||||||
<p class="text-xs text-white/70 truncate">{plant.commonName}</p>
|
<p class="text-xs text-white/70 truncate">{plant.commonName}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{@const waterText = getWateringText(plant.id)}
|
{#if getWateringText(plant.id)}
|
||||||
{#if waterText}
|
|
||||||
<div class="water-status {getWateringClass(plant.id)} mt-1">
|
<div class="water-status {getWateringClass(plant.id)} mt-1">
|
||||||
<span>💧</span>
|
<span>💧</span>
|
||||||
<span>{waterText}</span>
|
<span>{getWateringText(plant.id)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ const config = {
|
||||||
alias: {
|
alias: {
|
||||||
$lib: './src/lib',
|
$lib: './src/lib',
|
||||||
},
|
},
|
||||||
|
prerender: {
|
||||||
|
handleHttpError: ({ path, referrer, message }) => {
|
||||||
|
// Ignore missing favicon during prerender
|
||||||
|
if (path === '/favicon.png') return;
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,13 @@ const config = {
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
out: 'build',
|
out: 'build',
|
||||||
}),
|
}),
|
||||||
|
prerender: {
|
||||||
|
handleHttpError: ({ path, referrer, message }) => {
|
||||||
|
// Ignore missing favicon during prerender
|
||||||
|
if (path === '/favicon.png') return;
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
pageTitle="Wähle dein Abo"
|
pageTitle="Wähle dein Abo"
|
||||||
subscriptionsTitle="Abonnements"
|
subscriptionsTitle="Abonnements"
|
||||||
packagesTitle="Einmal-Pakete"
|
packagesTitle="Einmal-Pakete"
|
||||||
yearlyDiscount="2 Monate gratis"
|
yearlyDiscount="20% Rabatt"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
49
docs/APP_GAP_ANALYSIS.md
Normal file
49
docs/APP_GAP_ANALYSIS.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# App Gap Analysis - Mana Ecosystem
|
||||||
|
|
||||||
|
Stand: 2026-03-27
|
||||||
|
|
||||||
|
## Bestand
|
||||||
|
|
||||||
|
~20 aktive Apps decken Produktivitaet, Kreativitaet, Wissen, Kommunikation und Medien ab. Die Infrastruktur (Local-First, Sync, Auth, Media) ist ausgereift genug fuer schnelle neue Apps.
|
||||||
|
|
||||||
|
## Identifizierte Luecken
|
||||||
|
|
||||||
|
### 1. Notizen / Wiki
|
||||||
|
|
||||||
|
`Context` ist AI-Dokumenten-System, kein schneller Notizblock. Es fehlt eine einfache Markdown-Notiz-App mit schneller Erfassung und Verlinkung zwischen Notizen. Hoechste Prioritaet, da sie taegliche Nutzung am staerksten bindet.
|
||||||
|
|
||||||
|
### 2. Finanzen / Budget
|
||||||
|
|
||||||
|
Keine App fuer persoenliche Finanzen, Ausgaben-Tracking oder Budgetplanung. Finanzdaten sind besonders sensibel — starkes Argument fuer Self-Hosted + Local-First.
|
||||||
|
|
||||||
|
### 3. Bookmarks / Read-Later
|
||||||
|
|
||||||
|
Dediziertes Bookmark-/Read-Later-System fehlt. Niedriger Aufwand, da bestehende Services (`mana-crawler`, `mana-search`) direkt genutzt werden koennen.
|
||||||
|
|
||||||
|
### 4. Gewohnheiten / Health Tracking
|
||||||
|
|
||||||
|
Allgemeines Habit-Tracking, taegliche Routinen und Mood-Tracking fehlt. Ergaenzt Todo + Calendar + SkillTree natuerlich.
|
||||||
|
|
||||||
|
### 5. Zeiterfassung
|
||||||
|
|
||||||
|
Dediziertes Timetracking (Projekte, Kunden, Reports) fehlt. Relevant fuer Gilden-Feature und professionelle Nutzung.
|
||||||
|
|
||||||
|
### 6. Passwort-Manager
|
||||||
|
|
||||||
|
Bei komplett selbst betriebener Auth ohne External Providers waere ein eigener Passwort-Manager konsequent. Local-First + E2EE ideal.
|
||||||
|
|
||||||
|
### 7. E-Mail Client
|
||||||
|
|
||||||
|
Groesste Kommunikationsluecke (Chat/Matrix vorhanden, aber kein E-Mail). Allerdings auch das komplexeste Projekt.
|
||||||
|
|
||||||
|
## Priorisierung
|
||||||
|
|
||||||
|
| Prio | App | Aufwand | Begruendung |
|
||||||
|
|------|-----|---------|-------------|
|
||||||
|
| 1 | Notizen | Mittel | Grundbeduerfnis, bindet taegliche Nutzung |
|
||||||
|
| 2 | Finanzen | Mittel | Hohe Datensensibilitaet = starkes Self-Hosted-Argument |
|
||||||
|
| 3 | Bookmarks | Niedrig | Nutzt bestehende Services (Crawler, Search) |
|
||||||
|
| 4 | Habits | Niedrig | Natuerliche Ergaenzung zu Todo/Calendar/SkillTree |
|
||||||
|
| 5 | Timetracking | Mittel | Relevant fuer Gilden und Teams |
|
||||||
|
| 6 | Passwort-Manager | Hoch | Konsequent, aber E2EE-Implementierung komplex |
|
||||||
|
| 7 | E-Mail | Sehr hoch | Groesste Luecke, aber enormer Scope |
|
||||||
4070
pnpm-lock.yaml
generated
4070
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
16
services/mana-analytics/Dockerfile
Normal file
16
services/mana-analytics/Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM oven/bun:1 AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lock* ./
|
||||||
|
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY tsconfig.json drizzle.config.ts ./
|
||||||
|
|
||||||
|
EXPOSE 3064
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD bun -e "fetch('http://localhost:3064/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["bun", "run", "src/index.ts"]
|
||||||
67
services/mana-subscriptions/CLAUDE.md
Normal file
67
services/mana-subscriptions/CLAUDE.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# mana-subscriptions
|
||||||
|
|
||||||
|
Subscription and billing service. Extracted from mana-core-auth.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| **Runtime** | Bun |
|
||||||
|
| **Framework** | Hono |
|
||||||
|
| **Database** | PostgreSQL + Drizzle ORM |
|
||||||
|
| **Payments** | Stripe (Subscriptions, Billing Portal) |
|
||||||
|
| **Auth** | JWT validation via JWKS from mana-core-auth |
|
||||||
|
|
||||||
|
## Port: 3063
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev # Start with hot reload
|
||||||
|
bun run db:push # Push schema
|
||||||
|
bun run db:seed # Seed plans
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### User-facing (JWT auth)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/v1/subscriptions/plans` | List active plans |
|
||||||
|
| GET | `/api/v1/subscriptions/plans/:id` | Get plan details |
|
||||||
|
| GET | `/api/v1/subscriptions/current` | Current subscription |
|
||||||
|
| POST | `/api/v1/subscriptions/checkout` | Create Stripe checkout |
|
||||||
|
| POST | `/api/v1/subscriptions/portal` | Billing portal |
|
||||||
|
| POST | `/api/v1/subscriptions/cancel` | Cancel at period end |
|
||||||
|
| POST | `/api/v1/subscriptions/reactivate` | Reactivate canceled |
|
||||||
|
| GET | `/api/v1/subscriptions/invoices` | Invoice history |
|
||||||
|
|
||||||
|
### Internal (X-Service-Key)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/v1/internal/plan-limits/:userId` | Get plan limits (guilds) |
|
||||||
|
| GET | `/api/v1/internal/subscription/:userId` | Get user subscription |
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/v1/webhooks/stripe` | Subscription/invoice events |
|
||||||
|
|
||||||
|
## Database: `mana_subscriptions`
|
||||||
|
|
||||||
|
Tables: plans, subscriptions, invoices, stripe_customers
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
PORT=3063
|
||||||
|
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mana_subscriptions
|
||||||
|
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||||
|
MANA_CORE_SERVICE_KEY=dev-service-key
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
BASE_URL=http://localhost:3063
|
||||||
|
```
|
||||||
16
services/mana-subscriptions/Dockerfile
Normal file
16
services/mana-subscriptions/Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
FROM oven/bun:1 AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lock* ./
|
||||||
|
RUN bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY tsconfig.json drizzle.config.ts ./
|
||||||
|
|
||||||
|
EXPOSE 3063
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD bun -e "fetch('http://localhost:3063/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["bun", "run", "src/index.ts"]
|
||||||
13
services/mana-subscriptions/drizzle.config.ts
Normal file
13
services/mana-subscriptions/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema/*.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url:
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
'postgresql://manacore:devpassword@localhost:5432/mana_subscriptions',
|
||||||
|
},
|
||||||
|
schemaFilter: ['subscriptions'],
|
||||||
|
});
|
||||||
25
services/mana-subscriptions/package.json
Normal file
25
services/mana-subscriptions/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "@mana/subscriptions",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch src/index.ts",
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:seed": "bun run src/db/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0",
|
||||||
|
"drizzle-orm": "^0.38.3",
|
||||||
|
"postgres": "^3.4.5",
|
||||||
|
"stripe": "^17.5.0",
|
||||||
|
"jose": "^6.1.2",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"drizzle-kit": "^0.30.4",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
services/mana-subscriptions/src/config.ts
Normal file
28
services/mana-subscriptions/src/config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
export interface Config {
|
||||||
|
port: number;
|
||||||
|
databaseUrl: string;
|
||||||
|
manaAuthUrl: string;
|
||||||
|
serviceKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
stripe: { secretKey: string; webhookSecret: string };
|
||||||
|
cors: { origins: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
const env = (key: string, fallback?: string) => process.env[key] || fallback || '';
|
||||||
|
return {
|
||||||
|
port: parseInt(env('PORT', '3063'), 10),
|
||||||
|
databaseUrl: env(
|
||||||
|
'DATABASE_URL',
|
||||||
|
'postgresql://manacore:devpassword@localhost:5432/mana_subscriptions'
|
||||||
|
),
|
||||||
|
manaAuthUrl: env('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
|
||||||
|
serviceKey: env('MANA_CORE_SERVICE_KEY', 'dev-service-key'),
|
||||||
|
baseUrl: env('BASE_URL', 'http://localhost:3063'),
|
||||||
|
stripe: {
|
||||||
|
secretKey: env('STRIPE_SECRET_KEY'),
|
||||||
|
webhookSecret: env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
},
|
||||||
|
cors: { origins: env('CORS_ORIGINS', 'http://localhost:5173').split(',') },
|
||||||
|
};
|
||||||
|
}
|
||||||
19
services/mana-subscriptions/src/db/connection.ts
Normal file
19
services/mana-subscriptions/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Database connection using Drizzle ORM + postgres.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema/index';
|
||||||
|
|
||||||
|
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||||
|
|
||||||
|
export function getDb(databaseUrl: string) {
|
||||||
|
if (!db) {
|
||||||
|
const client = postgres(databaseUrl, { max: 10 });
|
||||||
|
db = drizzle(client, { schema });
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Database = ReturnType<typeof getDb>;
|
||||||
1
services/mana-subscriptions/src/db/schema/index.ts
Normal file
1
services/mana-subscriptions/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './subscriptions';
|
||||||
138
services/mana-subscriptions/src/db/schema/subscriptions.ts
Normal file
138
services/mana-subscriptions/src/db/schema/subscriptions.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* Subscriptions Schema — Plans, subscriptions, invoices
|
||||||
|
*
|
||||||
|
* Adapted from mana-core-auth: removed FK references to auth.users.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
pgSchema,
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
integer,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
jsonb,
|
||||||
|
index,
|
||||||
|
pgEnum,
|
||||||
|
boolean,
|
||||||
|
unique,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const subscriptionsSchema = pgSchema('subscriptions');
|
||||||
|
|
||||||
|
// ─── Enums ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const subscriptionStatusEnum = pgEnum('subscription_status', [
|
||||||
|
'active',
|
||||||
|
'canceled',
|
||||||
|
'past_due',
|
||||||
|
'unpaid',
|
||||||
|
'trialing',
|
||||||
|
'incomplete',
|
||||||
|
'incomplete_expired',
|
||||||
|
'paused',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const billingIntervalEnum = pgEnum('billing_interval', ['month', 'year']);
|
||||||
|
|
||||||
|
// ─── Tables ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Subscription plans */
|
||||||
|
export const plans = subscriptionsSchema.table('plans', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull().unique(),
|
||||||
|
description: text('description'),
|
||||||
|
monthlyCredits: integer('monthly_credits').default(0).notNull(),
|
||||||
|
priceMonthlyEuroCents: integer('price_monthly_euro_cents').default(0).notNull(),
|
||||||
|
priceYearlyEuroCents: integer('price_yearly_euro_cents').default(0).notNull(),
|
||||||
|
stripePriceIdMonthly: text('stripe_price_id_monthly'),
|
||||||
|
stripePriceIdYearly: text('stripe_price_id_yearly'),
|
||||||
|
stripeProductId: text('stripe_product_id'),
|
||||||
|
features: jsonb('features').default([]).notNull(),
|
||||||
|
maxTeamMembers: integer('max_team_members'),
|
||||||
|
maxOrganizations: integer('max_organizations'),
|
||||||
|
isDefault: boolean('is_default').default(false).notNull(),
|
||||||
|
isEnterprise: boolean('is_enterprise').default(false).notNull(),
|
||||||
|
active: boolean('active').default(true).notNull(),
|
||||||
|
sortOrder: integer('sort_order').default(0).notNull(),
|
||||||
|
metadata: jsonb('metadata'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** User subscriptions */
|
||||||
|
export const subscriptions = subscriptionsSchema.table(
|
||||||
|
'subscriptions',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
planId: uuid('plan_id')
|
||||||
|
.references(() => plans.id)
|
||||||
|
.notNull(),
|
||||||
|
stripeSubscriptionId: text('stripe_subscription_id').unique(),
|
||||||
|
stripeCustomerId: text('stripe_customer_id'),
|
||||||
|
stripePriceId: text('stripe_price_id'),
|
||||||
|
status: subscriptionStatusEnum('status').default('active').notNull(),
|
||||||
|
billingInterval: billingIntervalEnum('billing_interval').default('month').notNull(),
|
||||||
|
currentPeriodStart: timestamp('current_period_start', { withTimezone: true }),
|
||||||
|
currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }),
|
||||||
|
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(),
|
||||||
|
canceledAt: timestamp('canceled_at', { withTimezone: true }),
|
||||||
|
endedAt: timestamp('ended_at', { withTimezone: true }),
|
||||||
|
trialStart: timestamp('trial_start', { withTimezone: true }),
|
||||||
|
trialEnd: timestamp('trial_end', { withTimezone: true }),
|
||||||
|
metadata: jsonb('metadata'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index('subscriptions_user_id_idx').on(table.userId),
|
||||||
|
stripeSubIdIdx: index('subscriptions_stripe_sub_id_idx').on(table.stripeSubscriptionId),
|
||||||
|
statusIdx: index('subscriptions_status_idx').on(table.status),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Invoices */
|
||||||
|
export const invoices = subscriptionsSchema.table(
|
||||||
|
'invoices',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: text('user_id').notNull(),
|
||||||
|
subscriptionId: uuid('subscription_id').references(() => subscriptions.id),
|
||||||
|
stripeInvoiceId: text('stripe_invoice_id').unique(),
|
||||||
|
stripeCustomerId: text('stripe_customer_id'),
|
||||||
|
number: text('number'),
|
||||||
|
status: text('status'),
|
||||||
|
amountDueEuroCents: integer('amount_due_euro_cents').default(0).notNull(),
|
||||||
|
amountPaidEuroCents: integer('amount_paid_euro_cents').default(0).notNull(),
|
||||||
|
currency: text('currency').default('eur').notNull(),
|
||||||
|
hostedInvoiceUrl: text('hosted_invoice_url'),
|
||||||
|
invoicePdfUrl: text('invoice_pdf_url'),
|
||||||
|
periodStart: timestamp('period_start', { withTimezone: true }),
|
||||||
|
periodEnd: timestamp('period_end', { withTimezone: true }),
|
||||||
|
dueDate: timestamp('due_date', { withTimezone: true }),
|
||||||
|
paidAt: timestamp('paid_at', { withTimezone: true }),
|
||||||
|
metadata: jsonb('metadata'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index('invoices_user_id_idx').on(table.userId),
|
||||||
|
stripeInvoiceIdIdx: index('invoices_stripe_invoice_id_idx').on(table.stripeInvoiceId),
|
||||||
|
statusIdx: index('invoices_status_idx').on(table.status),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Stripe customer mapping (shared with mana-credits, may need dedup) */
|
||||||
|
export const stripeCustomers = subscriptionsSchema.table('stripe_customers', {
|
||||||
|
userId: text('user_id').primaryKey(),
|
||||||
|
stripeCustomerId: text('stripe_customer_id').unique().notNull(),
|
||||||
|
email: text('email'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Type Exports ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Plan = typeof plans.$inferSelect;
|
||||||
|
export type Subscription = typeof subscriptions.$inferSelect;
|
||||||
|
export type Invoice = typeof invoices.$inferSelect;
|
||||||
54
services/mana-subscriptions/src/index.ts
Normal file
54
services/mana-subscriptions/src/index.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* mana-subscriptions — Subscription & billing service
|
||||||
|
*
|
||||||
|
* Hono + Bun runtime. Extracted from mana-core-auth.
|
||||||
|
* Handles: plans, subscriptions, invoices, Stripe billing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { loadConfig } from './config';
|
||||||
|
import { getDb } from './db/connection';
|
||||||
|
import { errorHandler } from './middleware/error-handler';
|
||||||
|
import { jwtAuth } from './middleware/jwt-auth';
|
||||||
|
import { serviceAuth } from './middleware/service-auth';
|
||||||
|
import { StripeService } from './services/stripe';
|
||||||
|
import { SubscriptionsService } from './services/subscriptions';
|
||||||
|
import { healthRoutes } from './routes/health';
|
||||||
|
import { createSubscriptionRoutes } from './routes/subscriptions';
|
||||||
|
import { createInternalRoutes } from './routes/internal';
|
||||||
|
import { createWebhookRoutes } from './routes/stripe-webhook';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const db = getDb(config.databaseUrl);
|
||||||
|
|
||||||
|
const stripeService = new StripeService(db, config.stripe.secretKey);
|
||||||
|
const subscriptionsService = new SubscriptionsService(db, stripeService);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.onError(errorHandler);
|
||||||
|
app.use('*', cors({ origin: config.cors.origins, credentials: true }));
|
||||||
|
|
||||||
|
app.route('/health', healthRoutes);
|
||||||
|
|
||||||
|
// User-facing (JWT auth)
|
||||||
|
app.use('/api/v1/subscriptions/*', jwtAuth(config.manaAuthUrl));
|
||||||
|
app.route('/api/v1/subscriptions', createSubscriptionRoutes(subscriptionsService));
|
||||||
|
|
||||||
|
// Service-to-service (X-Service-Key)
|
||||||
|
app.use('/api/v1/internal/*', serviceAuth(config.serviceKey));
|
||||||
|
app.route('/api/v1/internal', createInternalRoutes(subscriptionsService));
|
||||||
|
|
||||||
|
// Stripe webhooks (signature verified, no auth)
|
||||||
|
app.route(
|
||||||
|
'/api/v1/webhooks',
|
||||||
|
createWebhookRoutes(stripeService, subscriptionsService, config.stripe.webhookSecret)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`mana-subscriptions starting on port ${config.port}...`);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
port: config.port,
|
||||||
|
fetch: app.fetch,
|
||||||
|
};
|
||||||
43
services/mana-subscriptions/src/lib/errors.ts
Normal file
43
services/mana-subscriptions/src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
|
||||||
|
export class BadRequestError extends HTTPException {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(400, { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends HTTPException {
|
||||||
|
constructor(message = 'Unauthorized') {
|
||||||
|
super(401, { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends HTTPException {
|
||||||
|
constructor(message = 'Forbidden') {
|
||||||
|
super(403, { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends HTTPException {
|
||||||
|
constructor(message = 'Not found') {
|
||||||
|
super(404, { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends HTTPException {
|
||||||
|
constructor(message = 'Conflict') {
|
||||||
|
super(409, { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsufficientCreditsError extends HTTPException {
|
||||||
|
constructor(
|
||||||
|
public readonly required: number,
|
||||||
|
public readonly available: number
|
||||||
|
) {
|
||||||
|
super(402, {
|
||||||
|
message: 'Insufficient credits',
|
||||||
|
cause: { required, available },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
29
services/mana-subscriptions/src/middleware/error-handler.ts
Normal file
29
services/mana-subscriptions/src/middleware/error-handler.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Global error handler middleware for Hono.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ErrorHandler } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
|
||||||
|
export const errorHandler: ErrorHandler = (err, c) => {
|
||||||
|
if (err instanceof HTTPException) {
|
||||||
|
const cause = err.cause as Record<string, unknown> | undefined;
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
statusCode: err.status,
|
||||||
|
message: err.message,
|
||||||
|
...(cause ? { details: cause } : {}),
|
||||||
|
},
|
||||||
|
err.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'Internal server error',
|
||||||
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
|
};
|
||||||
57
services/mana-subscriptions/src/middleware/jwt-auth.ts
Normal file
57
services/mana-subscriptions/src/middleware/jwt-auth.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* JWT Authentication Middleware
|
||||||
|
*
|
||||||
|
* Validates Bearer tokens via JWKS from mana-core-auth.
|
||||||
|
* Uses jose library with EdDSA algorithm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||||
|
import { UnauthorizedError } from '../lib/errors';
|
||||||
|
|
||||||
|
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
|
||||||
|
|
||||||
|
function getJwks(authUrl: string) {
|
||||||
|
if (!jwks) {
|
||||||
|
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
|
||||||
|
}
|
||||||
|
return jwks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that validates JWT tokens from Authorization: Bearer header.
|
||||||
|
* Sets c.set('user', { userId, email, role }) on success.
|
||||||
|
*/
|
||||||
|
export function jwtAuth(authUrl: string): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader?.startsWith('Bearer ')) {
|
||||||
|
throw new UnauthorizedError('Missing or invalid Authorization header');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, getJwks(authUrl), {
|
||||||
|
issuer: authUrl,
|
||||||
|
audience: 'manacore',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user: AuthUser = {
|
||||||
|
userId: payload.sub || '',
|
||||||
|
email: (payload.email as string) || '',
|
||||||
|
role: (payload.role as string) || 'user',
|
||||||
|
};
|
||||||
|
|
||||||
|
c.set('user', user);
|
||||||
|
await next();
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedError('Invalid or expired token');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
26
services/mana-subscriptions/src/middleware/service-auth.ts
Normal file
26
services/mana-subscriptions/src/middleware/service-auth.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/**
|
||||||
|
* Service-to-Service Authentication Middleware
|
||||||
|
*
|
||||||
|
* Validates X-Service-Key header for backend-to-backend calls.
|
||||||
|
* Used by /internal/* routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { UnauthorizedError } from '../lib/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that validates X-Service-Key header.
|
||||||
|
* Sets c.set('appId', ...) from X-App-Id header.
|
||||||
|
*/
|
||||||
|
export function serviceAuth(serviceKey: string): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
const key = c.req.header('X-Service-Key');
|
||||||
|
if (!key || key !== serviceKey) {
|
||||||
|
throw new UnauthorizedError('Invalid or missing service key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const appId = c.req.header('X-App-Id') || 'unknown';
|
||||||
|
c.set('appId', appId);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
5
services/mana-subscriptions/src/routes/health.ts
Normal file
5
services/mana-subscriptions/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
export const healthRoutes = new Hono().get('/', (c) =>
|
||||||
|
c.json({ status: 'ok', service: 'mana-subscriptions', timestamp: new Date().toISOString() })
|
||||||
|
);
|
||||||
19
services/mana-subscriptions/src/routes/internal.ts
Normal file
19
services/mana-subscriptions/src/routes/internal.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Internal routes — service-to-service (X-Service-Key auth)
|
||||||
|
* Used by guilds service to check plan limits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { SubscriptionsService } from '../services/subscriptions';
|
||||||
|
|
||||||
|
export function createInternalRoutes(subscriptionsService: SubscriptionsService) {
|
||||||
|
return new Hono()
|
||||||
|
.get('/plan-limits/:userId', async (c) => {
|
||||||
|
const limits = await subscriptionsService.getUserPlanLimits(c.req.param('userId'));
|
||||||
|
return c.json(limits);
|
||||||
|
})
|
||||||
|
.get('/subscription/:userId', async (c) => {
|
||||||
|
const sub = await subscriptionsService.getCurrentSubscription(c.req.param('userId'));
|
||||||
|
return c.json(sub);
|
||||||
|
});
|
||||||
|
}
|
||||||
43
services/mana-subscriptions/src/routes/stripe-webhook.ts
Normal file
43
services/mana-subscriptions/src/routes/stripe-webhook.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { StripeService } from '../services/stripe';
|
||||||
|
import type { SubscriptionsService } from '../services/subscriptions';
|
||||||
|
|
||||||
|
export function createWebhookRoutes(
|
||||||
|
stripeService: StripeService,
|
||||||
|
subscriptionsService: SubscriptionsService,
|
||||||
|
webhookSecret: string
|
||||||
|
) {
|
||||||
|
return new Hono().post('/stripe', async (c) => {
|
||||||
|
const signature = c.req.header('stripe-signature');
|
||||||
|
if (!signature) return c.json({ error: 'Missing signature' }, 400);
|
||||||
|
|
||||||
|
const rawBody = await c.req.text();
|
||||||
|
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = stripeService.verifyWebhookSignature(rawBody, signature, webhookSecret);
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'Invalid signature' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'customer.subscription.created':
|
||||||
|
case 'customer.subscription.updated':
|
||||||
|
case 'customer.subscription.deleted':
|
||||||
|
await subscriptionsService.handleSubscriptionUpdated(event.data.object as any);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'invoice.created':
|
||||||
|
case 'invoice.updated':
|
||||||
|
case 'invoice.paid':
|
||||||
|
case 'invoice.payment_failed':
|
||||||
|
await subscriptionsService.handleInvoiceUpdated(event.data.object as any);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log('Unhandled webhook event:', event.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ received: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
65
services/mana-subscriptions/src/routes/subscriptions.ts
Normal file
65
services/mana-subscriptions/src/routes/subscriptions.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { SubscriptionsService } from '../services/subscriptions';
|
||||||
|
import type { AuthUser } from '../middleware/jwt-auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const checkoutSchema = z.object({
|
||||||
|
planId: z.string().uuid(),
|
||||||
|
billingInterval: z.enum(['month', 'year']),
|
||||||
|
successUrl: z.string().url(),
|
||||||
|
cancelUrl: z.string().url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const portalSchema = z.object({
|
||||||
|
returnUrl: z.string().url(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createSubscriptionRoutes(subscriptionsService: SubscriptionsService) {
|
||||||
|
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||||
|
.get('/plans', async (c) => {
|
||||||
|
return c.json(await subscriptionsService.getPlans());
|
||||||
|
})
|
||||||
|
.get('/plans/:planId', async (c) => {
|
||||||
|
return c.json(await subscriptionsService.getPlan(c.req.param('planId')));
|
||||||
|
})
|
||||||
|
.get('/current', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
return c.json(await subscriptionsService.getCurrentSubscription(user.userId));
|
||||||
|
})
|
||||||
|
.post('/checkout', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = checkoutSchema.parse(await c.req.json());
|
||||||
|
const result = await subscriptionsService.createCheckoutSession(
|
||||||
|
user.userId,
|
||||||
|
user.email,
|
||||||
|
body.planId,
|
||||||
|
body.billingInterval,
|
||||||
|
body.successUrl,
|
||||||
|
body.cancelUrl
|
||||||
|
);
|
||||||
|
return c.json(result);
|
||||||
|
})
|
||||||
|
.post('/portal', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = portalSchema.parse(await c.req.json());
|
||||||
|
const result = await subscriptionsService.createPortalSession(
|
||||||
|
user.userId,
|
||||||
|
user.email,
|
||||||
|
body.returnUrl
|
||||||
|
);
|
||||||
|
return c.json(result);
|
||||||
|
})
|
||||||
|
.post('/cancel', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
return c.json(await subscriptionsService.cancelSubscription(user.userId));
|
||||||
|
})
|
||||||
|
.post('/reactivate', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
return c.json(await subscriptionsService.reactivateSubscription(user.userId));
|
||||||
|
})
|
||||||
|
.get('/invoices', async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const limit = parseInt(c.req.query('limit') || '20', 10);
|
||||||
|
return c.json(await subscriptionsService.getInvoices(user.userId, limit));
|
||||||
|
});
|
||||||
|
}
|
||||||
83
services/mana-subscriptions/src/services/stripe.ts
Normal file
83
services/mana-subscriptions/src/services/stripe.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* Stripe Service — Customer management, checkout, portal, webhook verification
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { stripeCustomers } from '../db/schema/subscriptions';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
|
||||||
|
export class StripeService {
|
||||||
|
private stripe: Stripe | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private db: Database,
|
||||||
|
secretKey: string
|
||||||
|
) {
|
||||||
|
this.stripe = secretKey ? new Stripe(secretKey, { apiVersion: '2025-04-30.basil' }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStripe(): Stripe {
|
||||||
|
if (!this.stripe) throw new Error('Stripe not configured');
|
||||||
|
return this.stripe;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrCreateCustomer(userId: string, email: string): Promise<string> {
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(stripeCustomers)
|
||||||
|
.where(eq(stripeCustomers.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) return existing.stripeCustomerId;
|
||||||
|
|
||||||
|
const customer = await this.getStripe().customers.create({ email, metadata: { userId } });
|
||||||
|
|
||||||
|
await this.db
|
||||||
|
.insert(stripeCustomers)
|
||||||
|
.values({ userId, stripeCustomerId: customer.id, email })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
return customer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCheckoutSession(params: {
|
||||||
|
customerId: string;
|
||||||
|
priceId: string;
|
||||||
|
successUrl: string;
|
||||||
|
cancelUrl: string;
|
||||||
|
metadata: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
return this.getStripe().checkout.sessions.create({
|
||||||
|
customer: params.customerId,
|
||||||
|
mode: 'subscription',
|
||||||
|
line_items: [{ price: params.priceId, quantity: 1 }],
|
||||||
|
success_url: params.successUrl,
|
||||||
|
cancel_url: params.cancelUrl,
|
||||||
|
metadata: params.metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPortalSession(customerId: string, returnUrl: string) {
|
||||||
|
return this.getStripe().billingPortal.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
return_url: returnUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelSubscription(subscriptionId: string) {
|
||||||
|
return this.getStripe().subscriptions.update(subscriptionId, {
|
||||||
|
cancel_at_period_end: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivateSubscription(subscriptionId: string) {
|
||||||
|
return this.getStripe().subscriptions.update(subscriptionId, {
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyWebhookSignature(body: string | Buffer, signature: string, secret: string): Stripe.Event {
|
||||||
|
return this.getStripe().webhooks.constructEvent(body, signature, secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
222
services/mana-subscriptions/src/services/subscriptions.ts
Normal file
222
services/mana-subscriptions/src/services/subscriptions.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
/**
|
||||||
|
* Subscriptions Service — Plans, billing, invoices
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { plans, subscriptions, invoices, stripeCustomers } from '../db/schema/subscriptions';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
import type { StripeService } from './stripe';
|
||||||
|
import { NotFoundError, BadRequestError } from '../lib/errors';
|
||||||
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
|
const DEFAULT_FREE_PLAN = {
|
||||||
|
id: 'free',
|
||||||
|
name: 'Free',
|
||||||
|
monthlyCredits: 0,
|
||||||
|
priceMonthlyEuroCents: 0,
|
||||||
|
priceYearlyEuroCents: 0,
|
||||||
|
features: [],
|
||||||
|
maxTeamMembers: 1,
|
||||||
|
maxOrganizations: 1,
|
||||||
|
isDefault: true,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SubscriptionsService {
|
||||||
|
constructor(
|
||||||
|
private db: Database,
|
||||||
|
private stripeService: StripeService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getPlans() {
|
||||||
|
return this.db.select().from(plans).where(eq(plans.active, true)).orderBy(plans.sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlan(planId: string) {
|
||||||
|
const [plan] = await this.db.select().from(plans).where(eq(plans.id, planId)).limit(1);
|
||||||
|
if (!plan) throw new NotFoundError('Plan not found');
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentSubscription(userId: string) {
|
||||||
|
const [sub] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active')))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
// Return default free plan
|
||||||
|
const [defaultPlan] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plans)
|
||||||
|
.where(eq(plans.isDefault, true))
|
||||||
|
.limit(1);
|
||||||
|
return { subscription: null, plan: defaultPlan || DEFAULT_FREE_PLAN };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [plan] = await this.db.select().from(plans).where(eq(plans.id, sub.planId)).limit(1);
|
||||||
|
return { subscription: sub, plan: plan || DEFAULT_FREE_PLAN };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get plan limits for a user (used by guilds service) */
|
||||||
|
async getUserPlanLimits(userId: string) {
|
||||||
|
const { plan } = await this.getCurrentSubscription(userId);
|
||||||
|
return {
|
||||||
|
maxTeamMembers: plan.maxTeamMembers ?? 1,
|
||||||
|
maxOrganizations: plan.maxOrganizations ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCheckoutSession(
|
||||||
|
userId: string,
|
||||||
|
userEmail: string,
|
||||||
|
planId: string,
|
||||||
|
billingInterval: 'month' | 'year',
|
||||||
|
successUrl: string,
|
||||||
|
cancelUrl: string
|
||||||
|
) {
|
||||||
|
const [plan] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(plans)
|
||||||
|
.where(and(eq(plans.id, planId), eq(plans.active, true)))
|
||||||
|
.limit(1);
|
||||||
|
if (!plan) throw new NotFoundError('Plan not found');
|
||||||
|
|
||||||
|
const priceId =
|
||||||
|
billingInterval === 'year' ? plan.stripePriceIdYearly : plan.stripePriceIdMonthly;
|
||||||
|
if (!priceId) throw new BadRequestError('Stripe price not configured for this plan');
|
||||||
|
|
||||||
|
const customerId = await this.stripeService.getOrCreateCustomer(userId, userEmail);
|
||||||
|
|
||||||
|
const session = await this.stripeService.createCheckoutSession({
|
||||||
|
customerId,
|
||||||
|
priceId,
|
||||||
|
successUrl,
|
||||||
|
cancelUrl,
|
||||||
|
metadata: { userId, planId, billingInterval },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sessionId: session.id, url: session.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPortalSession(userId: string, userEmail: string, returnUrl: string) {
|
||||||
|
const customerId = await this.stripeService.getOrCreateCustomer(userId, userEmail);
|
||||||
|
const session = await this.stripeService.createPortalSession(customerId, returnUrl);
|
||||||
|
return { url: session.url };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelSubscription(userId: string) {
|
||||||
|
const [sub] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.status, 'active')))
|
||||||
|
.limit(1);
|
||||||
|
if (!sub?.stripeSubscriptionId) throw new NotFoundError('No active subscription');
|
||||||
|
|
||||||
|
await this.stripeService.cancelSubscription(sub.stripeSubscriptionId);
|
||||||
|
await this.db
|
||||||
|
.update(subscriptions)
|
||||||
|
.set({ cancelAtPeriodEnd: true, canceledAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(eq(subscriptions.id, sub.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivateSubscription(userId: string) {
|
||||||
|
const [sub] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(and(eq(subscriptions.userId, userId), eq(subscriptions.cancelAtPeriodEnd, true)))
|
||||||
|
.limit(1);
|
||||||
|
if (!sub?.stripeSubscriptionId)
|
||||||
|
throw new NotFoundError('No canceled subscription to reactivate');
|
||||||
|
|
||||||
|
await this.stripeService.reactivateSubscription(sub.stripeSubscriptionId);
|
||||||
|
await this.db
|
||||||
|
.update(subscriptions)
|
||||||
|
.set({ cancelAtPeriodEnd: false, canceledAt: null, updatedAt: new Date() })
|
||||||
|
.where(eq(subscriptions.id, sub.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoices(userId: string, limit = 20) {
|
||||||
|
return this.db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.userId, userId))
|
||||||
|
.orderBy(desc(invoices.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Webhook Handlers ─────────────────────────────────────
|
||||||
|
|
||||||
|
async handleSubscriptionUpdated(stripeSub: Stripe.Subscription) {
|
||||||
|
const userId = stripeSub.metadata?.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const planId = stripeSub.metadata?.planId;
|
||||||
|
const priceId = stripeSub.items.data[0]?.price?.id;
|
||||||
|
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(subscriptions)
|
||||||
|
.where(eq(subscriptions.stripeSubscriptionId, stripeSub.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
userId,
|
||||||
|
planId: planId || existing?.planId || '',
|
||||||
|
stripeSubscriptionId: stripeSub.id,
|
||||||
|
stripeCustomerId: stripeSub.customer as string,
|
||||||
|
stripePriceId: priceId,
|
||||||
|
status: stripeSub.status as any,
|
||||||
|
currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
|
||||||
|
currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
|
||||||
|
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
|
||||||
|
canceledAt: stripeSub.canceled_at ? new Date(stripeSub.canceled_at * 1000) : null,
|
||||||
|
endedAt: stripeSub.ended_at ? new Date(stripeSub.ended_at * 1000) : null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await this.db.update(subscriptions).set(data).where(eq(subscriptions.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await this.db.insert(subscriptions).values(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleInvoiceUpdated(stripeInvoice: Stripe.Invoice) {
|
||||||
|
const userId =
|
||||||
|
stripeInvoice.metadata?.userId || stripeInvoice.subscription_details?.metadata?.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
userId,
|
||||||
|
stripeInvoiceId: stripeInvoice.id,
|
||||||
|
stripeCustomerId: stripeInvoice.customer as string,
|
||||||
|
number: stripeInvoice.number,
|
||||||
|
status: stripeInvoice.status,
|
||||||
|
amountDueEuroCents: stripeInvoice.amount_due,
|
||||||
|
amountPaidEuroCents: stripeInvoice.amount_paid,
|
||||||
|
currency: stripeInvoice.currency,
|
||||||
|
hostedInvoiceUrl: stripeInvoice.hosted_invoice_url,
|
||||||
|
invoicePdfUrl: stripeInvoice.invoice_pdf,
|
||||||
|
periodStart: stripeInvoice.period_start ? new Date(stripeInvoice.period_start * 1000) : null,
|
||||||
|
periodEnd: stripeInvoice.period_end ? new Date(stripeInvoice.period_end * 1000) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [existing] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.stripeInvoiceId, stripeInvoice.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await this.db.update(invoices).set(data).where(eq(invoices.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await this.db.insert(invoices).values(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
services/mana-subscriptions/tsconfig.json
Normal file
13
services/mana-subscriptions/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue