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:
Till JS 2026-03-28 10:27:35 +01:00
parent 5d02b0419d
commit 8ccf8ff818
36 changed files with 1691 additions and 3468 deletions

4
.gitignore vendored
View file

@ -18,6 +18,9 @@ ios/
# Turbo
.turbo/
# MCP config (contains API keys)
.mcp.json
# Environment files
.env
.env.local
@ -127,3 +130,4 @@ pip-delete-this-directory.txt
# ML Models (large files, downloaded on demand)
mlx_models/
services/matrix-stt-bot/data/
services/mana-sync/server

View file

@ -1,3 +1,7 @@
import { writable } from 'svelte/store';
import { createSimpleNavigationStores } from '@manacore/shared-stores';
export const { isNavCollapsed } = createSimpleNavigationStores();
/** Whether the app is in sidebar mode (e.g., embedded in another app) */
export const isSidebarMode = writable(false);

View file

@ -211,10 +211,10 @@
</div>
<!-- Custom Alarms (Grid) -->
{@const customAlarms = allAlarms.value.filter(
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
)}
{#if customAlarms.length > 0}
{#if allAlarms.value.filter((a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))).length > 0}
{@const customAlarms = allAlarms.value.filter(
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
)}
<div class="mt-4">
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('alarm.custom')}

View file

@ -8,6 +8,13 @@ const config = {
adapter: adapter({
out: 'build',
}),
prerender: {
handleHttpError: ({ path, referrer, message }) => {
// Ignore missing favicon during prerender
if (path === '/favicon.png') return;
throw new Error(message);
},
},
},
};

View file

@ -53,6 +53,7 @@
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"svelte-i18n": "^4.0.1"
},
"type": "module"

View file

@ -60,7 +60,7 @@ const { showOneTime = false } = Astro.props;
Jährlich
<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"
>2 Monate gratis</span
>20% Rabatt</span
>
</button>
</div>

View file

@ -41,7 +41,7 @@ import { pricingPlans } from '../data/pricing.js';
<button class="toggle-btn active" data-type="monthly"> Monatlich </button>
<button class="toggle-btn" data-type="yearly">
Jährlich
<span class="badge">2 Monate gratis</span>
<span class="badge">20% Rabatt</span>
</button>
</div>
</section>

View file

@ -36,7 +36,7 @@
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
yearlyDiscount="20% Rabatt"
/>
</div>

View file

@ -8,6 +8,13 @@ const config = {
adapter: adapter({
out: 'build',
}),
prerender: {
handleHttpError: ({ path, referrer, message }) => {
// Ignore missing favicon during prerender
if (path === '/favicon.png') return;
throw new Error(message);
},
},
},
};

View file

@ -96,11 +96,10 @@
{#if plant.commonName}
<p class="text-xs text-white/70 truncate">{plant.commonName}</p>
{/if}
{@const waterText = getWateringText(plant.id)}
{#if waterText}
{#if getWateringText(plant.id)}
<div class="water-status {getWateringClass(plant.id)} mt-1">
<span>💧</span>
<span>{waterText}</span>
<span>{getWateringText(plant.id)}</span>
</div>
{/if}
</div>

View file

@ -11,6 +11,13 @@ const config = {
alias: {
$lib: './src/lib',
},
prerender: {
handleHttpError: ({ path, referrer, message }) => {
// Ignore missing favicon during prerender
if (path === '/favicon.png') return;
throw new Error(message);
},
},
},
};

View file

@ -8,6 +8,13 @@ const config = {
adapter: adapter({
out: 'build',
}),
prerender: {
handleHttpError: ({ path, referrer, message }) => {
// Ignore missing favicon during prerender
if (path === '/favicon.png') return;
throw new Error(message);
},
},
},
};

View file

@ -25,7 +25,7 @@
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
yearlyDiscount="20% Rabatt"
/>
</div>

49
docs/APP_GAP_ANALYSIS.md Normal file
View 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

File diff suppressed because it is too large Load diff

View 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"]

View 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
```

View 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"]

View 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'],
});

View 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"
}
}

View 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(',') },
};
}

View 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>;

View file

@ -0,0 +1 @@
export * from './subscriptions';

View 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;

View 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,
};

View 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 },
});
}
}

View 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
);
};

View 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');
}
};
}

View 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();
};
}

View 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() })
);

View 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);
});
}

View 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 });
});
}

View 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));
});
}

View 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);
}
}

View 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);
}
}
}

View 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"]
}