mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +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/
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="2 Monate gratis"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
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