mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
chore(cutover): remove services/mana-credits/ — moved to mana-platform
Live containers on the Mac Mini build out of `../mana/services/mana-credits/`
since the 8-Doppel-Cutover commit (774852ba2). Smoke test green
2026-05-08 — health endpoints, JWKS, login flow, Stripe-webhook all
reachable from the new build path. Removing the now-stale duplicate.
Was 156K in this repo, gone now. Active code lives in
`Code/mana/services/mana-credits/` (siehe ../mana/CLAUDE.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fcc36eadcb
commit
af3f21a179
29 changed files with 0 additions and 2704 deletions
|
|
@ -1,143 +0,0 @@
|
|||
# mana-credits
|
||||
|
||||
Standalone credit management service for the Mana ecosystem. Extracted from mana-auth.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Runtime** | Bun |
|
||||
| **Framework** | Hono |
|
||||
| **Database** | PostgreSQL + Drizzle ORM |
|
||||
| **Payments** | Stripe (Payment Intents, Checkout Sessions) |
|
||||
| **Auth** | JWT validation via JWKS from mana-auth |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start (requires PostgreSQL running)
|
||||
bun run dev
|
||||
|
||||
# Database
|
||||
bun run db:push # Push schema
|
||||
bun run db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## Port: 3061
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Personal Credits (JWT auth)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/credits/balance` | Get personal balance |
|
||||
| POST | `/api/v1/credits/use` | Use credits |
|
||||
| GET | `/api/v1/credits/transactions` | Transaction history |
|
||||
| GET | `/api/v1/credits/purchases` | Purchase history |
|
||||
| GET | `/api/v1/credits/packages` | Available packages |
|
||||
| POST | `/api/v1/credits/purchase` | Initiate Stripe purchase |
|
||||
| GET | `/api/v1/credits/purchase/:id` | Purchase status |
|
||||
|
||||
### Sync Billing (JWT auth)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/sync/status` | Sync subscription status |
|
||||
| POST | `/api/v1/sync/activate` | Activate sync (body: `{ interval }`) |
|
||||
| POST | `/api/v1/sync/deactivate` | Deactivate sync |
|
||||
| POST | `/api/v1/sync/change-interval` | Change billing interval |
|
||||
|
||||
### Gift Codes (Mixed auth)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/gifts/:code` | Get gift info (public) |
|
||||
| POST | `/api/v1/gifts` | Create gift (JWT) |
|
||||
| GET | `/api/v1/gifts/me/created` | My created gifts (JWT) |
|
||||
| GET | `/api/v1/gifts/me/received` | My received gifts (JWT) |
|
||||
| POST | `/api/v1/gifts/:code/redeem` | Redeem gift (JWT) |
|
||||
| DELETE | `/api/v1/gifts/:id` | Cancel gift (JWT) |
|
||||
|
||||
### Admin (JWT auth + `role=admin`)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/admin/sync/:userId` | Get sync status for any user |
|
||||
| POST | `/api/v1/admin/sync/:userId/gift` | Grant Cloud Sync as a gift (no credits charged, no recurring billing) |
|
||||
| DELETE | `/api/v1/admin/sync/:userId/gift` | Revoke a sync gift (deactivates sync) |
|
||||
|
||||
Gifted subscriptions have `is_gifted=true` and are skipped by the billing cron — they stay active indefinitely until revoked. The user-facing `/activate` and `/deactivate` endpoints refuse to touch gifted rows.
|
||||
|
||||
### Internal (X-Service-Key auth)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/internal/credits/balance/:userId` | Get user balance |
|
||||
| POST | `/api/v1/internal/credits/use` | Use credits for user (one-shot debit) |
|
||||
| POST | `/api/v1/internal/credits/refund` | Refund credits (unrelated to reservations) |
|
||||
| POST | `/api/v1/internal/credits/init` | Initialize balance |
|
||||
| POST | `/api/v1/internal/credits/reserve` | 2-phase debit: reserve (body: `{ userId, amount, reason }`) → returns `{ reservationId, balance }` |
|
||||
| POST | `/api/v1/internal/credits/commit` | 2-phase debit: commit (body: `{ reservationId, description? }`) → ledger entry |
|
||||
| POST | `/api/v1/internal/credits/refund-reservation` | 2-phase debit: refund (body: `{ reservationId }`) → restore balance |
|
||||
| POST | `/api/v1/internal/gifts/redeem-pending` | Auto-redeem on registration |
|
||||
| GET | `/api/v1/internal/sync/status/:userId` | Sync status for server check |
|
||||
| POST | `/api/v1/internal/sync/charge-recurring` | Cron trigger for billing |
|
||||
|
||||
### Webhooks
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/webhooks/stripe` | Stripe payment events |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
PORT=3061
|
||||
DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_credits
|
||||
MANA_AUTH_URL=http://localhost:3001
|
||||
MANA_SERVICE_KEY=dev-service-key
|
||||
BASE_URL=http://localhost:3061
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:5180
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Own database: `mana_credits`
|
||||
|
||||
Schemas: `credits.*`, `gifts.*`
|
||||
|
||||
Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, reservations, gift_codes, gift_redemptions, sync_subscriptions
|
||||
|
||||
## 2-Phase Debit (Reserve/Commit/Refund)
|
||||
|
||||
For services that need to charge only after a downstream call succeeds (e.g. mana-research fanning out to paid API providers), use the `/internal/credits/{reserve,commit,refund-reservation}` flow:
|
||||
|
||||
1. `reserve` — atomically deducts balance, creates row in `credits.reservations` with status `reserved`. Returns `reservationId`.
|
||||
2. `commit` — marks reservation `committed`, writes transaction ledger entry.
|
||||
3. `refund-reservation` — marks reservation `refunded`, restores balance.
|
||||
|
||||
One-shot `use` remains for synchronous operations that charge immediately.
|
||||
|
||||
## Credit Operations
|
||||
|
||||
Credits are only charged for operations that cost real money:
|
||||
- **AI operations** (2-25 credits): Chat with GPT-4/Claude/Gemini, image generation, research, food/plant analysis
|
||||
- **Premium features** (1-3 credits): PDF export, bulk import, premium themes
|
||||
- **Cloud Sync** (30 credits/month, 90/quarter, 360/year): Multi-device sync via mana-sync
|
||||
|
||||
Local-first CRUD operations (tasks, events, contacts, etc.) are **free** — they happen in IndexedDB with no server cost.
|
||||
|
||||
## Sync Billing
|
||||
|
||||
Cloud Sync is a monthly credit subscription. Users start in local-only mode and opt-in via Settings. Billing intervals: monthly (30), quarterly (90), yearly (360). 1 Credit = 1 Cent.
|
||||
|
||||
When credits run out, sync is paused (not deleted). Local data is preserved. User sees an in-app banner and can reactivate after topping up credits.
|
||||
|
||||
**Gifted sync**: Admins can grant sync via `POST /api/v1/admin/sync/:userId/gift`. Gifted rows (`is_gifted=true`) are immune to the billing cron and never get paused for insufficient credits. Revoke with `DELETE /api/v1/admin/sync/:userId/gift`.
|
||||
|
||||
## Gift Types
|
||||
|
||||
Two gift types: `simple` (anyone with code can redeem) and `personalized` (auto-redeemed when target email registers). Each gift is single-use.
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# Install stage: use node + pnpm to resolve workspace dependencies
|
||||
FROM node:22-alpine AS installer
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace structure
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY services/mana-credits/package.json ./services/mana-credits/
|
||||
COPY packages/shared-hono ./packages/shared-hono
|
||||
COPY packages/shared-logger ./packages/shared-logger
|
||||
COPY packages/shared-types ./packages/shared-types
|
||||
|
||||
# Install only mana-credits-service and its workspace deps.
|
||||
# Note the suffix — the workspace name is `@mana/credits-service`, not
|
||||
# `@mana/credits` (which is a different package under packages/credits).
|
||||
RUN pnpm install --filter @mana/credits-service... --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
# Runtime stage: bun
|
||||
FROM oven/bun:1 AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy installed deps from installer stage
|
||||
COPY --from=installer /app/node_modules ./node_modules
|
||||
COPY --from=installer /app/services/mana-credits/node_modules ./services/mana-credits/node_modules
|
||||
COPY --from=installer /app/packages ./packages
|
||||
|
||||
# Copy source
|
||||
COPY services/mana-credits/package.json ./services/mana-credits/
|
||||
COPY services/mana-credits/src ./services/mana-credits/src
|
||||
COPY services/mana-credits/tsconfig.json services/mana-credits/drizzle.config.ts ./services/mana-credits/
|
||||
|
||||
WORKDIR /app/services/mana-credits
|
||||
|
||||
EXPOSE 3061
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3061/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema/*.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url:
|
||||
process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform',
|
||||
},
|
||||
schemaFilter: ['credits', 'gifts'],
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
-- 0001_grant_transaction_type.sql
|
||||
--
|
||||
-- Phase 3.A.1 von docs/plans/feedback-rewards-and-identity.md.
|
||||
--
|
||||
-- Erweitert credits.transaction_type um 'grant' für Reward-Auszahlungen
|
||||
-- (mana-analytics ruft /internal/credits/grant für +5 / +500 / +25
|
||||
-- Belohnungen auf). Plus ein partial-Index auf metadata.referenceId,
|
||||
-- damit Idempotency-Lookup beim Re-Grant in O(log n) läuft.
|
||||
--
|
||||
-- Apply manually before next `pnpm db:push`:
|
||||
-- psql "$DATABASE_URL" -f services/mana-credits/drizzle/0001_grant_transaction_type.sql
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS / ADD VALUE IF NOT EXISTS.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TYPE public.transaction_type ADD VALUE IF NOT EXISTS 'grant';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS transactions_reference_id_idx
|
||||
ON credits.transactions ((metadata ->> 'referenceId'))
|
||||
WHERE metadata ->> 'referenceId' IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"name": "@mana/credits-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mana/shared-hono": "workspace:*",
|
||||
"hono": "^4.7.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"stripe": "^17.5.0",
|
||||
"jose": "^6.1.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* Application configuration loaded from environment variables.
|
||||
*/
|
||||
|
||||
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 requiredEnv = (key: string, fallback?: string): string => {
|
||||
const value = process.env[key] || fallback;
|
||||
if (!value) throw new Error(`Missing required env var: ${key}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
port: parseInt(process.env.PORT || '3061', 10),
|
||||
databaseUrl: requiredEnv(
|
||||
'DATABASE_URL',
|
||||
'postgresql://mana:devpassword@localhost:5432/mana_platform'
|
||||
),
|
||||
manaAuthUrl: requiredEnv('MANA_AUTH_URL', 'http://localhost:3001'),
|
||||
serviceKey: requiredEnv('MANA_SERVICE_KEY', 'dev-service-key'),
|
||||
baseUrl: requiredEnv('BASE_URL', 'http://localhost:3060'),
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||
},
|
||||
cors: {
|
||||
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/**
|
||||
* 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,153 +0,0 @@
|
|||
/**
|
||||
* Credits Schema — Personal balance, transactions, packages, purchases
|
||||
*
|
||||
* Adapted from mana-auth: removed FK references to auth.users (separate DB).
|
||||
* userId columns remain as text() without foreign key constraints.
|
||||
*/
|
||||
|
||||
import {
|
||||
pgSchema,
|
||||
uuid,
|
||||
integer,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
pgEnum,
|
||||
boolean,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
export const creditsSchema = pgSchema('credits');
|
||||
|
||||
// ─── Enums ──────────────────────────────────────────────────
|
||||
|
||||
// Enum values must mirror cross-service consumers. New values need a
|
||||
// hand-authored SQL migration (drizzle-kit push won't add enum members
|
||||
// reliably across mana-credits' own pgEnum installs).
|
||||
export const transactionTypeEnum = pgEnum('transaction_type', [
|
||||
'purchase',
|
||||
'usage',
|
||||
'refund',
|
||||
'gift',
|
||||
'grant',
|
||||
]);
|
||||
|
||||
export const transactionStatusEnum = pgEnum('transaction_status', [
|
||||
'pending',
|
||||
'completed',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
// ─── Tables ─────────────────────────────────────────────────
|
||||
|
||||
/** Stripe customer mapping (reuse customers across purchases) */
|
||||
export const stripeCustomers = creditsSchema.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(),
|
||||
});
|
||||
|
||||
/** Credit balances (one per user, optimistic locking via version) */
|
||||
export const balances = creditsSchema.table('balances', {
|
||||
userId: text('user_id').primaryKey(),
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
totalEarned: integer('total_earned').default(0).notNull(),
|
||||
totalSpent: integer('total_spent').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
/** Immutable transaction ledger */
|
||||
export const transactions = creditsSchema.table(
|
||||
'transactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
type: transactionTypeEnum('type').notNull(),
|
||||
status: transactionStatusEnum('status').default('pending').notNull(),
|
||||
amount: integer('amount').notNull(),
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
appId: text('app_id').notNull(),
|
||||
description: text('description').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('transactions_user_id_idx').on(table.userId),
|
||||
appIdIdx: index('transactions_app_id_idx').on(table.appId),
|
||||
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
|
||||
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
|
||||
})
|
||||
);
|
||||
|
||||
/** Credit packages (pricing tiers) */
|
||||
export const packages = creditsSchema.table('packages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
credits: integer('credits').notNull(),
|
||||
priceEuroCents: integer('price_euro_cents').notNull(),
|
||||
stripePriceId: text('stripe_price_id').unique(),
|
||||
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(),
|
||||
});
|
||||
|
||||
/** Purchase history */
|
||||
export const purchases = creditsSchema.table(
|
||||
'purchases',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
packageId: uuid('package_id').references(() => packages.id),
|
||||
credits: integer('credits').notNull(),
|
||||
priceEuroCents: integer('price_euro_cents').notNull(),
|
||||
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
status: transactionStatusEnum('status').default('pending').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('purchases_user_id_idx').on(table.userId),
|
||||
stripePaymentIntentIdIdx: index('purchases_stripe_payment_intent_id_idx').on(
|
||||
table.stripePaymentIntentId
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
/** Usage tracking (analytics) */
|
||||
export const usageStats = creditsSchema.table(
|
||||
'usage_stats',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
appId: text('app_id').notNull(),
|
||||
creditsUsed: integer('credits_used').notNull(),
|
||||
date: timestamp('date', { withTimezone: true }).notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
},
|
||||
(table) => ({
|
||||
userIdDateIdx: index('usage_stats_user_id_date_idx').on(table.userId, table.date),
|
||||
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
|
||||
})
|
||||
);
|
||||
|
||||
// ─── Type Exports ───────────────────────────────────────────
|
||||
|
||||
export type Balance = typeof balances.$inferSelect;
|
||||
export type Transaction = typeof transactions.$inferSelect;
|
||||
export type NewTransaction = typeof transactions.$inferInsert;
|
||||
export type Package = typeof packages.$inferSelect;
|
||||
export type Purchase = typeof purchases.$inferSelect;
|
||||
export type NewPurchase = typeof purchases.$inferInsert;
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
/**
|
||||
* Gifts Schema — Gift codes, redemptions
|
||||
*
|
||||
* Adapted from mana-auth: removed FK references to auth.users.
|
||||
* Added denormalized creatorName for display without cross-service calls.
|
||||
*/
|
||||
|
||||
import { pgSchema, uuid, text, timestamp, integer, index, pgEnum } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const giftsSchema = pgSchema('gifts');
|
||||
|
||||
// ─── Enums ──────────────────────────────────────────────────
|
||||
|
||||
export const giftCodeTypeEnum = pgEnum('gift_code_type', ['simple', 'personalized']);
|
||||
|
||||
export const giftCodeStatusEnum = pgEnum('gift_code_status', [
|
||||
'active',
|
||||
'depleted',
|
||||
'expired',
|
||||
'cancelled',
|
||||
'refunded',
|
||||
]);
|
||||
|
||||
export const giftRedemptionStatusEnum = pgEnum('gift_redemption_status', [
|
||||
'success',
|
||||
'failed_wrong_user',
|
||||
'failed_depleted',
|
||||
'failed_expired',
|
||||
'failed_already_claimed',
|
||||
]);
|
||||
|
||||
// ─── Tables ─────────────────────────────────────────────────
|
||||
|
||||
/** Gift codes — user-generated codes for gifting credits */
|
||||
export const giftCodes = giftsSchema.table(
|
||||
'gift_codes',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
code: text('code').notNull().unique(),
|
||||
shortUrl: text('short_url'),
|
||||
|
||||
creatorId: text('creator_id').notNull(),
|
||||
creatorName: text('creator_name'), // Denormalized for display
|
||||
|
||||
// Credit allocation
|
||||
totalCredits: integer('total_credits').notNull(),
|
||||
redeemed: integer('redeemed').notNull().default(0), // 0 = unclaimed, 1 = claimed
|
||||
|
||||
// Type and status
|
||||
type: giftCodeTypeEnum('type').notNull().default('simple'),
|
||||
status: giftCodeStatusEnum('status').notNull().default('active'),
|
||||
|
||||
// Personalization
|
||||
targetEmail: text('target_email'),
|
||||
|
||||
// Message
|
||||
message: text('message'),
|
||||
|
||||
// Expiration
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
|
||||
// Reference to credit reservation transaction
|
||||
reservationTransactionId: uuid('reservation_transaction_id'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
codeLookupIdx: index('gift_codes_code_idx').on(table.code),
|
||||
creatorIdx: index('gift_codes_creator_idx').on(table.creatorId),
|
||||
statusIdx: index('gift_codes_status_idx').on(table.status),
|
||||
expiresAtIdx: index('gift_codes_expires_at_idx').on(table.expiresAt),
|
||||
})
|
||||
);
|
||||
|
||||
/** Gift redemptions — tracks each redemption attempt */
|
||||
export const giftRedemptions = giftsSchema.table(
|
||||
'gift_redemptions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
giftCodeId: uuid('gift_code_id')
|
||||
.notNull()
|
||||
.references(() => giftCodes.id, { onDelete: 'cascade' }),
|
||||
redeemerUserId: text('redeemer_user_id').notNull(),
|
||||
|
||||
status: giftRedemptionStatusEnum('status').notNull(),
|
||||
creditsReceived: integer('credits_received').notNull().default(0),
|
||||
|
||||
creditTransactionId: uuid('credit_transaction_id'),
|
||||
sourceAppId: text('source_app_id'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
giftCodeIdx: index('gift_redemptions_gift_code_idx').on(table.giftCodeId),
|
||||
redeemerIdx: index('gift_redemptions_redeemer_idx').on(table.redeemerUserId),
|
||||
statusIdx: index('gift_redemptions_status_idx').on(table.status),
|
||||
})
|
||||
);
|
||||
|
||||
// ─── Type Exports ───────────────────────────────────────────
|
||||
|
||||
export type GiftCode = typeof giftCodes.$inferSelect;
|
||||
export type NewGiftCode = typeof giftCodes.$inferInsert;
|
||||
export type GiftRedemption = typeof giftRedemptions.$inferSelect;
|
||||
export type NewGiftRedemption = typeof giftRedemptions.$inferInsert;
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────
|
||||
|
||||
export const GIFT_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
export const GIFT_CODE_LENGTH = 6;
|
||||
|
||||
export const GIFT_CODE_RULES = {
|
||||
minCredits: 1,
|
||||
maxCredits: 10000,
|
||||
maxMessageLength: 500,
|
||||
defaultExpirationDays: 90,
|
||||
} as const;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './credits';
|
||||
export * from './gifts';
|
||||
export * from './sync';
|
||||
export * from './reservations';
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/**
|
||||
* Credit Reservations — 2-phase debit for services that need to charge only
|
||||
* after a downstream call succeeds (e.g. mana-research provider fan-out).
|
||||
*
|
||||
* Flow:
|
||||
* reserve() — deduct balance atomically, row with status='reserved'
|
||||
* commit() — finalize, row becomes 'committed', ledger entry written
|
||||
* refund() — restore balance, row becomes 'refunded'
|
||||
*/
|
||||
|
||||
import { pgEnum, text, timestamp, integer, uuid, index } from 'drizzle-orm/pg-core';
|
||||
import { creditsSchema } from './credits';
|
||||
|
||||
export const reservationStatusEnum = pgEnum('reservation_status', [
|
||||
'reserved',
|
||||
'committed',
|
||||
'refunded',
|
||||
]);
|
||||
|
||||
export const creditReservations = creditsSchema.table(
|
||||
'reservations',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
amount: integer('amount').notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
status: reservationStatusEnum('status').default('reserved').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index('reservations_user_id_idx').on(t.userId),
|
||||
statusIdx: index('reservations_status_idx').on(t.status),
|
||||
})
|
||||
);
|
||||
|
||||
export type CreditReservation = typeof creditReservations.$inferSelect;
|
||||
export type NewCreditReservation = typeof creditReservations.$inferInsert;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* Sync Billing Schema — sync subscription tracking
|
||||
*/
|
||||
|
||||
import { text, integer, boolean, timestamp, pgEnum } from 'drizzle-orm/pg-core';
|
||||
import { creditsSchema } from './credits';
|
||||
|
||||
// ─── Enums ──────────────────────────────────────────────────
|
||||
|
||||
export const syncBillingIntervalEnum = pgEnum('sync_billing_interval', [
|
||||
'monthly',
|
||||
'quarterly',
|
||||
'yearly',
|
||||
]);
|
||||
|
||||
// ─── Tables ─────────────────────────────────────────────────
|
||||
|
||||
/** Sync subscriptions — one per user, tracks billing state */
|
||||
export const syncSubscriptions = creditsSchema.table('sync_subscriptions', {
|
||||
userId: text('user_id').primaryKey(),
|
||||
active: boolean('active').default(false).notNull(),
|
||||
billingInterval: syncBillingIntervalEnum('billing_interval').notNull().default('monthly'),
|
||||
amountCharged: integer('amount_charged').notNull().default(30),
|
||||
activatedAt: timestamp('activated_at', { withTimezone: true }),
|
||||
nextChargeAt: timestamp('next_charge_at', { withTimezone: true }),
|
||||
pausedAt: timestamp('paused_at', { withTimezone: true }),
|
||||
// Gift flag: when true, billing cron skips this row and `active` stays on indefinitely.
|
||||
// Set via admin endpoints; immune to insufficient-credits pauses.
|
||||
isGifted: boolean('is_gifted').default(false).notNull(),
|
||||
giftedBy: text('gifted_by'),
|
||||
giftedAt: timestamp('gifted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// ─── Type Exports ───────────────────────────────────────────
|
||||
|
||||
export type SyncSubscription = typeof syncSubscriptions.$inferSelect;
|
||||
export type NewSyncSubscription = typeof syncSubscriptions.$inferInsert;
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
/**
|
||||
* mana-credits — Standalone credit management service
|
||||
*
|
||||
* Hono + Bun runtime. Extracted from mana-auth.
|
||||
* Handles: personal credits, gift codes, sync billing, Stripe payments.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { loadConfig } from './config';
|
||||
import { getDb } from './db/connection';
|
||||
import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
|
||||
import { jwtAuth } from './middleware/jwt-auth';
|
||||
import { serviceAuth } from './middleware/service-auth';
|
||||
import { StripeService } from './services/stripe';
|
||||
import { CreditsService } from './services/credits';
|
||||
import { GiftCodeService } from './services/gift-code';
|
||||
import { SyncBillingService } from './services/sync-billing';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createCreditsRoutes } from './routes/credits';
|
||||
import { createGiftRoutes } from './routes/gifts';
|
||||
import { createSyncRoutes } from './routes/sync';
|
||||
import { createAdminRoutes } from './routes/admin';
|
||||
import { createInternalRoutes } from './routes/internal';
|
||||
import { createWebhookRoutes } from './routes/stripe-webhook';
|
||||
|
||||
// ─── Bootstrap ──────────────────────────────────────────────
|
||||
|
||||
const config = loadConfig();
|
||||
const db = getDb(config.databaseUrl);
|
||||
|
||||
// Instantiate services (manual DI — no NestJS)
|
||||
const stripeService = new StripeService(db, config.stripe.secretKey);
|
||||
const creditsService = new CreditsService(db, stripeService);
|
||||
const giftCodeService = new GiftCodeService(db, config.baseUrl);
|
||||
const syncBillingService = new SyncBillingService(db, creditsService);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Global middleware
|
||||
app.onError(errorHandler);
|
||||
app.use(
|
||||
'*',
|
||||
cors({
|
||||
origin: config.cors.origins,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Health check (no auth)
|
||||
app.route('/health', healthRoutes);
|
||||
|
||||
// User-facing routes (JWT auth)
|
||||
app.use('/api/v1/credits/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/credits', createCreditsRoutes(creditsService));
|
||||
|
||||
app.use('/api/v1/sync/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/sync', createSyncRoutes(syncBillingService));
|
||||
|
||||
// Admin routes (JWT auth + admin role check inside)
|
||||
app.use('/api/v1/admin/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/admin', createAdminRoutes(syncBillingService));
|
||||
|
||||
// Gift routes (mixed: public GET /:code, JWT for rest)
|
||||
app.route('/api/v1/gifts', createGiftRoutes(giftCodeService, config.manaAuthUrl));
|
||||
|
||||
// Service-to-service routes (X-Service-Key auth)
|
||||
app.use('/api/v1/internal/*', serviceAuth(config.serviceKey));
|
||||
app.route(
|
||||
'/api/v1/internal',
|
||||
createInternalRoutes(creditsService, giftCodeService, syncBillingService)
|
||||
);
|
||||
|
||||
// Stripe webhooks (verified by signature, no auth middleware)
|
||||
app.route(
|
||||
'/api/v1/webhooks',
|
||||
createWebhookRoutes(stripeService, creditsService, config.stripe.webhookSecret)
|
||||
);
|
||||
|
||||
// ─── Cron: Daily sync billing charge ────────────────────────
|
||||
|
||||
const CHARGE_INTERVAL_MS = 60 * 60 * 1000; // Check every hour
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const result = await syncBillingService.chargeRecurring();
|
||||
if (result.total > 0) {
|
||||
console.log(
|
||||
`[sync-billing] Recurring charge: ${result.charged} charged, ${result.paused} paused, ${result.errors} errors`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[sync-billing] Recurring charge failed:', err);
|
||||
}
|
||||
}, CHARGE_INTERVAL_MS);
|
||||
|
||||
// ─── Start ──────────────────────────────────────────────────
|
||||
|
||||
console.log(`mana-credits starting on port ${config.port}...`);
|
||||
|
||||
export default {
|
||||
port: config.port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/**
|
||||
* Zod schemas for request body validation.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ─── Credits ────────────────────────────────────────────────
|
||||
|
||||
export const useCreditsSchema = z.object({
|
||||
amount: z.number().positive(),
|
||||
appId: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
idempotencyKey: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const purchaseCreditsSchema = z.object({
|
||||
packageId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const createPaymentLinkSchema = z.object({
|
||||
packageId: z.string().uuid(),
|
||||
quantity: z.number().int().positive().default(1),
|
||||
});
|
||||
|
||||
// ─── Gifts ──────────────────────────────────────────────────
|
||||
|
||||
export const createGiftSchema = z.object({
|
||||
totalCredits: z.number().int().positive().min(1).max(10000),
|
||||
type: z.enum(['simple', 'personalized']).default('simple'),
|
||||
targetEmail: z.string().email().optional(),
|
||||
message: z.string().max(500).optional(),
|
||||
expirationDays: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const redeemGiftSchema = z.object({
|
||||
sourceAppId: z.string().optional(),
|
||||
});
|
||||
|
||||
// ─── Sync ──────────────────────────────────────────────────
|
||||
|
||||
export const activateSyncSchema = z.object({
|
||||
interval: z.enum(['monthly', 'quarterly', 'yearly']).default('monthly'),
|
||||
});
|
||||
|
||||
export const changeSyncIntervalSchema = z.object({
|
||||
interval: z.enum(['monthly', 'quarterly', 'yearly']),
|
||||
});
|
||||
|
||||
// ─── Internal (Service-to-Service) ──────────────────────────
|
||||
|
||||
export const internalUseCreditsSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
amount: z.number().positive(),
|
||||
appId: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
idempotencyKey: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const internalRefundSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
amount: z.number().positive(),
|
||||
description: z.string().min(1),
|
||||
appId: z.string().default('system'),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const internalInitSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const internalRedeemPendingSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export const internalGrantSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
amount: z.number().int().positive(),
|
||||
reason: z.string().min(1).max(64),
|
||||
referenceId: z.string().min(1).max(128),
|
||||
description: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
// ─── Reservations (2-phase debit) ──────────────────────────
|
||||
|
||||
export const internalReserveSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
amount: z.number().int().positive(),
|
||||
reason: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
export const internalCommitSchema = z.object({
|
||||
reservationId: z.string().uuid(),
|
||||
description: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const internalRefundReservationSchema = z.object({
|
||||
reservationId: z.string().uuid(),
|
||||
});
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
/**
|
||||
* JWT Authentication Middleware
|
||||
*
|
||||
* Validates Bearer tokens via JWKS from mana-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: 'mana',
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
/**
|
||||
* 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();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/**
|
||||
* Admin routes — privileged ops (JWT auth + admin role check).
|
||||
*
|
||||
* Currently exposes sync-gift management. Extend here as more
|
||||
* admin-scoped credit operations are needed.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { SyncBillingService } from '../services/sync-billing';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
|
||||
export function createAdminRoutes(syncBillingService: SyncBillingService) {
|
||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const user = c.get('user');
|
||||
if (user.role !== 'admin') {
|
||||
return c.json({ error: 'Forbidden', message: 'Admin access required' }, 403);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.post('/sync/:userId/gift', async (c) => {
|
||||
const { userId } = c.req.param();
|
||||
const admin = c.get('user');
|
||||
const result = await syncBillingService.grantSyncGift(userId, admin.userId);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
app.delete('/sync/:userId/gift', async (c) => {
|
||||
const { userId } = c.req.param();
|
||||
const result = await syncBillingService.revokeSyncGift(userId);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
app.get('/sync/:userId', async (c) => {
|
||||
const { userId } = c.req.param();
|
||||
const status = await syncBillingService.getSyncStatus(userId);
|
||||
return c.json(status);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* Credit routes — personal balance endpoints (JWT auth)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { CreditsService } from '../services/credits';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import { useCreditsSchema, purchaseCreditsSchema } from '../lib/validation';
|
||||
|
||||
export function createCreditsRoutes(creditsService: CreditsService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/balance', async (c) => {
|
||||
const user = c.get('user');
|
||||
const balance = await creditsService.getBalance(user.userId);
|
||||
return c.json(balance);
|
||||
})
|
||||
.post('/use', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = useCreditsSchema.parse(await c.req.json());
|
||||
const result = await creditsService.useCredits(user.userId, body);
|
||||
return c.json(result);
|
||||
})
|
||||
.get('/transactions', async (c) => {
|
||||
const user = c.get('user');
|
||||
const limit = parseInt(c.req.query('limit') || '50', 10);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
const txs = await creditsService.getTransactions(user.userId, limit, offset);
|
||||
return c.json(txs);
|
||||
})
|
||||
.get('/purchases', async (c) => {
|
||||
const user = c.get('user');
|
||||
const purchases = await creditsService.getPurchases(user.userId);
|
||||
return c.json(purchases);
|
||||
})
|
||||
.get('/packages', async (c) => {
|
||||
const pkgs = await creditsService.getPackages();
|
||||
return c.json(pkgs);
|
||||
})
|
||||
.post('/purchase', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = purchaseCreditsSchema.parse(await c.req.json());
|
||||
const result = await creditsService.initiatePurchase(user.userId, body.packageId, user.email);
|
||||
return c.json(result);
|
||||
})
|
||||
.get('/purchase/:purchaseId', async (c) => {
|
||||
const user = c.get('user');
|
||||
const purchase = await creditsService.getPurchaseStatus(
|
||||
user.userId,
|
||||
c.req.param('purchaseId')
|
||||
);
|
||||
return c.json(purchase);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* Gift code routes — mixed auth (public GET /:code, JWT for rest)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { GiftCodeService } from '../services/gift-code';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import { jwtAuth } from '../middleware/jwt-auth';
|
||||
import { createGiftSchema, redeemGiftSchema } from '../lib/validation';
|
||||
|
||||
export function createGiftRoutes(giftCodeService: GiftCodeService, authUrl: string) {
|
||||
const app = new Hono();
|
||||
|
||||
// Public: Get gift info by code
|
||||
app.get('/:code', async (c) => {
|
||||
const info = await giftCodeService.getGiftInfo(c.req.param('code'));
|
||||
return c.json(info);
|
||||
});
|
||||
|
||||
// Protected routes
|
||||
const authed = new Hono<{ Variables: { user: AuthUser } }>();
|
||||
authed.use('*', jwtAuth(authUrl));
|
||||
|
||||
authed.post('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = createGiftSchema.parse(await c.req.json());
|
||||
const gift = await giftCodeService.createGift(user.userId, user.email, body);
|
||||
return c.json(gift, 201);
|
||||
});
|
||||
|
||||
authed.get('/me/created', async (c) => {
|
||||
const user = c.get('user');
|
||||
const gifts = await giftCodeService.getCreatedGifts(user.userId);
|
||||
return c.json(gifts);
|
||||
});
|
||||
|
||||
authed.get('/me/received', async (c) => {
|
||||
const user = c.get('user');
|
||||
const gifts = await giftCodeService.getReceivedGifts(user.userId);
|
||||
return c.json(gifts);
|
||||
});
|
||||
|
||||
authed.post('/:code/redeem', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = redeemGiftSchema.parse(await c.req.json());
|
||||
const result = await giftCodeService.redeemGift(
|
||||
c.req.param('code'),
|
||||
user.userId,
|
||||
body.sourceAppId
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
authed.delete('/:id', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await giftCodeService.cancelGift(c.req.param('id'), user.userId);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
app.route('/', authed);
|
||||
return app;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoutes = new Hono().get('/', (c) =>
|
||||
c.json({ status: 'ok', service: 'mana-credits', timestamp: new Date().toISOString() })
|
||||
);
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
/**
|
||||
* Internal routes — service-to-service endpoints (X-Service-Key auth)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { CreditsService } from '../services/credits';
|
||||
import type { GiftCodeService } from '../services/gift-code';
|
||||
import type { SyncBillingService } from '../services/sync-billing';
|
||||
import {
|
||||
internalUseCreditsSchema,
|
||||
internalRefundSchema,
|
||||
internalInitSchema,
|
||||
internalRedeemPendingSchema,
|
||||
internalReserveSchema,
|
||||
internalCommitSchema,
|
||||
internalRefundReservationSchema,
|
||||
internalGrantSchema,
|
||||
} from '../lib/validation';
|
||||
|
||||
export function createInternalRoutes(
|
||||
creditsService: CreditsService,
|
||||
giftCodeService: GiftCodeService,
|
||||
syncBillingService: SyncBillingService
|
||||
) {
|
||||
return new Hono()
|
||||
.get('/credits/balance/:userId', async (c) => {
|
||||
const balance = await creditsService.getBalance(c.req.param('userId'));
|
||||
return c.json(balance);
|
||||
})
|
||||
.post('/credits/use', async (c) => {
|
||||
const body = internalUseCreditsSchema.parse(await c.req.json());
|
||||
const { userId, ...params } = body;
|
||||
const result = await creditsService.useCredits(userId, params);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/credits/refund', async (c) => {
|
||||
const body = internalRefundSchema.parse(await c.req.json());
|
||||
const result = await creditsService.refundCredits(
|
||||
body.userId,
|
||||
body.amount,
|
||||
body.description,
|
||||
body.appId,
|
||||
body.metadata
|
||||
);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/credits/grant', async (c) => {
|
||||
const body = internalGrantSchema.parse(await c.req.json());
|
||||
const result = await creditsService.grantCredits(body);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/credits/init', async (c) => {
|
||||
const body = internalInitSchema.parse(await c.req.json());
|
||||
const balance = await creditsService.initializeBalance(body.userId);
|
||||
return c.json(balance);
|
||||
})
|
||||
.post('/credits/reserve', async (c) => {
|
||||
const body = internalReserveSchema.parse(await c.req.json());
|
||||
const result = await creditsService.reserve(body.userId, body.amount, body.reason);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/credits/commit', async (c) => {
|
||||
const body = internalCommitSchema.parse(await c.req.json());
|
||||
const result = await creditsService.commitReservation(body.reservationId, body.description);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/credits/refund-reservation', async (c) => {
|
||||
const body = internalRefundReservationSchema.parse(await c.req.json());
|
||||
const result = await creditsService.refundReservation(body.reservationId);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/gifts/redeem-pending', async (c) => {
|
||||
const body = internalRedeemPendingSchema.parse(await c.req.json());
|
||||
const result = await giftCodeService.redeemPendingForUser(body.userId, body.email);
|
||||
return c.json(result);
|
||||
})
|
||||
.get('/sync/status/:userId', async (c) => {
|
||||
const status = await syncBillingService.getSyncStatus(c.req.param('userId'));
|
||||
return c.json(status);
|
||||
})
|
||||
.post('/sync/charge-recurring', async (c) => {
|
||||
const result = await syncBillingService.chargeRecurring();
|
||||
return c.json(result);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/**
|
||||
* Stripe webhook handler — credit-related events only
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { StripeService } from '../services/stripe';
|
||||
import type { CreditsService } from '../services/credits';
|
||||
|
||||
export function createWebhookRoutes(
|
||||
stripeService: StripeService,
|
||||
creditsService: CreditsService,
|
||||
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 'payment_intent.succeeded': {
|
||||
const pi = event.data.object;
|
||||
await creditsService.completePurchase(pi.id);
|
||||
break;
|
||||
}
|
||||
case 'payment_intent.payment_failed': {
|
||||
const pi = event.data.object;
|
||||
await creditsService.failPurchase(pi.id);
|
||||
break;
|
||||
}
|
||||
case 'checkout.session.completed': {
|
||||
// Checkout sessions create PaymentIntents which trigger payment_intent.succeeded
|
||||
// No additional action needed here unless using direct checkout mode
|
||||
break;
|
||||
}
|
||||
case 'payment_intent.processing': {
|
||||
// SEPA debit processing — no action needed, wait for success/failure
|
||||
console.log('Payment processing (SEPA):', event.data.object.id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log('Unhandled webhook event:', event.type);
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
/**
|
||||
* Sync billing routes — user-facing endpoints (JWT auth)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { SyncBillingService } from '../services/sync-billing';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import { activateSyncSchema, changeSyncIntervalSchema } from '../lib/validation';
|
||||
|
||||
export function createSyncRoutes(syncBillingService: SyncBillingService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/status', async (c) => {
|
||||
const user = c.get('user');
|
||||
const status = await syncBillingService.getSyncStatus(user.userId);
|
||||
return c.json(status);
|
||||
})
|
||||
.post('/activate', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = activateSyncSchema.parse(await c.req.json());
|
||||
const result = await syncBillingService.activateSync(user.userId, body.interval);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/deactivate', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await syncBillingService.deactivateSync(user.userId);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/change-interval', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = changeSyncIntervalSchema.parse(await c.req.json());
|
||||
const result = await syncBillingService.changeBillingInterval(user.userId, body.interval);
|
||||
return c.json(result);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,592 +0,0 @@
|
|||
/**
|
||||
* Credits Service — Personal balance management
|
||||
*
|
||||
* Ported from mana-auth CreditsService.
|
||||
* Handles balance CRUD, credit usage, purchases, and transaction ledger.
|
||||
*/
|
||||
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { balances, transactions, purchases, packages, usageStats } from '../db/schema/credits';
|
||||
import { creditReservations } from '../db/schema/reservations';
|
||||
import type { Database } from '../db/connection';
|
||||
import type { StripeService } from './stripe';
|
||||
import {
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
InsufficientCreditsError,
|
||||
} from '../lib/errors';
|
||||
|
||||
interface UseCreditsParams {
|
||||
amount: number;
|
||||
appId: string;
|
||||
description: string;
|
||||
idempotencyKey?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class CreditsService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private stripeService: StripeService
|
||||
) {}
|
||||
|
||||
async initializeBalance(userId: string) {
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
const [balance] = await this.db
|
||||
.insert(balances)
|
||||
.values({ userId, balance: 0, totalEarned: 0, totalSpent: 0 })
|
||||
.returning();
|
||||
|
||||
return balance;
|
||||
}
|
||||
|
||||
async getBalance(userId: string) {
|
||||
const [balance] = await this.db
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!balance) return this.initializeBalance(userId);
|
||||
|
||||
return {
|
||||
balance: balance.balance,
|
||||
totalEarned: balance.totalEarned,
|
||||
totalSpent: balance.totalSpent,
|
||||
};
|
||||
}
|
||||
|
||||
async useCredits(userId: string, params: UseCreditsParams) {
|
||||
// Idempotency check
|
||||
if (params.idempotencyKey) {
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(eq(transactions.idempotencyKey, params.idempotencyKey))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return { success: true, transaction: existing, message: 'Transaction already processed' };
|
||||
}
|
||||
}
|
||||
|
||||
return await this.db.transaction(async (tx) => {
|
||||
// Row lock
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!current) throw new NotFoundError('User balance not found');
|
||||
if (current.balance < params.amount) {
|
||||
throw new InsufficientCreditsError(params.amount, current.balance);
|
||||
}
|
||||
|
||||
const newBalance = current.balance - params.amount;
|
||||
const newTotalSpent = current.totalSpent + params.amount;
|
||||
|
||||
// Optimistic locking update
|
||||
const updateResult = await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalSpent: newTotalSpent,
|
||||
version: current.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, userId), eq(balances.version, current.version)))
|
||||
.returning();
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
throw new ConflictError('Balance was modified concurrently. Please retry.');
|
||||
}
|
||||
|
||||
// Ledger entry
|
||||
const [transaction] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId,
|
||||
type: 'usage',
|
||||
status: 'completed',
|
||||
amount: -params.amount,
|
||||
balanceBefore: current.balance,
|
||||
balanceAfter: newBalance,
|
||||
appId: params.appId,
|
||||
description: params.description,
|
||||
metadata: params.metadata,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Usage stats
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
await tx.insert(usageStats).values({
|
||||
userId,
|
||||
appId: params.appId,
|
||||
creditsUsed: params.amount,
|
||||
date: today,
|
||||
metadata: params.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction,
|
||||
newBalance: { balance: newBalance, totalSpent: newTotalSpent },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async refundCredits(
|
||||
userId: string,
|
||||
amount: number,
|
||||
description: string,
|
||||
appId = 'system',
|
||||
metadata?: Record<string, unknown>
|
||||
) {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!current) throw new NotFoundError('User balance not found');
|
||||
|
||||
const newBalance = current.balance + amount;
|
||||
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalEarned: current.totalEarned + amount,
|
||||
version: current.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, userId), eq(balances.version, current.version)))
|
||||
.returning();
|
||||
|
||||
const [transaction] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId,
|
||||
type: 'refund',
|
||||
status: 'completed',
|
||||
amount,
|
||||
balanceBefore: current.balance,
|
||||
balanceAfter: newBalance,
|
||||
appId,
|
||||
description,
|
||||
metadata,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { success: true, transaction, newBalance: { balance: newBalance } };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant credits to a user as a reward (no money changed hands).
|
||||
* Idempotent on `referenceId`: if a previous grant with the same
|
||||
* referenceId already landed, returns `alreadyGranted: true` without
|
||||
* mutating balance.
|
||||
*
|
||||
* Used by mana-analytics to drop +5 Credits for every quality
|
||||
* feedback submission and +500 Credits when a wish ships, plus +25
|
||||
* to each reactioner whose vote nudged the wish toward 'completed'.
|
||||
*/
|
||||
async grantCredits(params: {
|
||||
userId: string;
|
||||
amount: number;
|
||||
reason: string;
|
||||
referenceId: string;
|
||||
description?: string;
|
||||
}) {
|
||||
if (params.amount <= 0) throw new BadRequestError('amount must be > 0');
|
||||
if (!params.referenceId) throw new BadRequestError('referenceId is required for idempotency');
|
||||
|
||||
// Idempotency: short-circuit if this referenceId already produced a grant.
|
||||
const existing = await this.db
|
||||
.select({ id: transactions.id, balanceAfter: transactions.balanceAfter })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.userId, params.userId),
|
||||
eq(transactions.type, 'grant'),
|
||||
sql`${transactions.metadata}->>'referenceId' = ${params.referenceId}`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return { ok: true, alreadyGranted: true, newBalance: existing[0].balanceAfter };
|
||||
}
|
||||
|
||||
return await this.db.transaction(async (tx) => {
|
||||
// Ensure balance row exists.
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, params.userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
let balanceBefore: number;
|
||||
let totalEarnedBefore: number;
|
||||
let version: number;
|
||||
if (!current) {
|
||||
const [created] = await tx
|
||||
.insert(balances)
|
||||
.values({ userId: params.userId, balance: 0, totalEarned: 0, totalSpent: 0 })
|
||||
.returning();
|
||||
balanceBefore = created.balance;
|
||||
totalEarnedBefore = created.totalEarned;
|
||||
version = created.version;
|
||||
} else {
|
||||
balanceBefore = current.balance;
|
||||
totalEarnedBefore = current.totalEarned;
|
||||
version = current.version;
|
||||
}
|
||||
|
||||
const newBalance = balanceBefore + params.amount;
|
||||
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalEarned: totalEarnedBefore + params.amount, // grants count as earned
|
||||
version: version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, params.userId), eq(balances.version, version)));
|
||||
|
||||
const [transaction] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId: params.userId,
|
||||
type: 'grant',
|
||||
status: 'completed',
|
||||
amount: params.amount,
|
||||
balanceBefore,
|
||||
balanceAfter: newBalance,
|
||||
appId: 'community',
|
||||
description: params.description ?? `Reward: ${params.reason}`,
|
||||
metadata: { reason: params.reason, referenceId: params.referenceId },
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { ok: true, alreadyGranted: false, newBalance, transactionId: transaction.id };
|
||||
});
|
||||
}
|
||||
|
||||
async getTransactions(userId: string, limit = 50, offset = 0) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(eq(transactions.userId, userId))
|
||||
.orderBy(desc(transactions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async getPurchases(userId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(purchases)
|
||||
.where(eq(purchases.userId, userId))
|
||||
.orderBy(desc(purchases.createdAt));
|
||||
}
|
||||
|
||||
async getPackages() {
|
||||
return this.db
|
||||
.select()
|
||||
.from(packages)
|
||||
.where(eq(packages.active, true))
|
||||
.orderBy(packages.sortOrder);
|
||||
}
|
||||
|
||||
async initiatePurchase(userId: string, packageId: string, userEmail: string) {
|
||||
const [pkg] = await this.db
|
||||
.select()
|
||||
.from(packages)
|
||||
.where(and(eq(packages.id, packageId), eq(packages.active, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!pkg) throw new NotFoundError('Package not found or inactive');
|
||||
|
||||
const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, userEmail);
|
||||
|
||||
const [purchase] = await this.db
|
||||
.insert(purchases)
|
||||
.values({
|
||||
userId,
|
||||
packageId,
|
||||
credits: pkg.credits,
|
||||
priceEuroCents: pkg.priceEuroCents,
|
||||
stripeCustomerId,
|
||||
status: 'pending',
|
||||
})
|
||||
.returning();
|
||||
|
||||
const paymentIntent = await this.stripeService.createPaymentIntent(
|
||||
stripeCustomerId,
|
||||
pkg.priceEuroCents,
|
||||
{ userId, packageId, purchaseId: purchase.id }
|
||||
);
|
||||
|
||||
await this.db
|
||||
.update(purchases)
|
||||
.set({ stripePaymentIntentId: paymentIntent.id })
|
||||
.where(eq(purchases.id, purchase.id));
|
||||
|
||||
return {
|
||||
purchaseId: purchase.id,
|
||||
clientSecret: paymentIntent.client_secret!,
|
||||
amount: pkg.priceEuroCents,
|
||||
credits: pkg.credits,
|
||||
};
|
||||
}
|
||||
|
||||
async completePurchase(paymentIntentId: string) {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [purchase] = await tx
|
||||
.select()
|
||||
.from(purchases)
|
||||
.where(eq(purchases.stripePaymentIntentId, paymentIntentId))
|
||||
.limit(1);
|
||||
|
||||
if (!purchase) throw new NotFoundError('Purchase not found');
|
||||
if (purchase.status === 'completed') return { success: true, already: true };
|
||||
|
||||
// Lock and update balance
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, purchase.userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const balanceBefore = current?.balance ?? 0;
|
||||
const newBalance = balanceBefore + purchase.credits;
|
||||
|
||||
if (current) {
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalEarned: current.totalEarned + purchase.credits,
|
||||
version: current.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(balances.userId, purchase.userId));
|
||||
} else {
|
||||
await tx.insert(balances).values({
|
||||
userId: purchase.userId,
|
||||
balance: purchase.credits,
|
||||
totalEarned: purchase.credits,
|
||||
totalSpent: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Ledger entry
|
||||
await tx.insert(transactions).values({
|
||||
userId: purchase.userId,
|
||||
type: 'purchase',
|
||||
status: 'completed',
|
||||
amount: purchase.credits,
|
||||
balanceBefore,
|
||||
balanceAfter: newBalance,
|
||||
appId: 'stripe',
|
||||
description: `Credit purchase: ${purchase.credits} credits`,
|
||||
metadata: { purchaseId: purchase.id, paymentIntentId },
|
||||
completedAt: new Date(),
|
||||
});
|
||||
|
||||
// Mark purchase complete
|
||||
await tx
|
||||
.update(purchases)
|
||||
.set({ status: 'completed', completedAt: new Date() })
|
||||
.where(eq(purchases.id, purchase.id));
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
async failPurchase(paymentIntentId: string) {
|
||||
await this.db
|
||||
.update(purchases)
|
||||
.set({ status: 'failed' })
|
||||
.where(eq(purchases.stripePaymentIntentId, paymentIntentId));
|
||||
}
|
||||
|
||||
async getPurchaseStatus(userId: string, purchaseId: string) {
|
||||
const [purchase] = await this.db
|
||||
.select()
|
||||
.from(purchases)
|
||||
.where(and(eq(purchases.id, purchaseId), eq(purchases.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!purchase) throw new NotFoundError('Purchase not found');
|
||||
return purchase;
|
||||
}
|
||||
|
||||
// ─── 2-phase debit (reserve / commit / refund) ─────────────
|
||||
// Used by mana-research for provider calls that should only be charged
|
||||
// after the downstream API succeeds. See services/mana-research.
|
||||
|
||||
async reserve(userId: string, amount: number, reason: string) {
|
||||
if (amount <= 0) throw new BadRequestError('Reservation amount must be positive');
|
||||
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!current) throw new NotFoundError('User balance not found');
|
||||
if (current.balance < amount) {
|
||||
throw new InsufficientCreditsError(amount, current.balance);
|
||||
}
|
||||
|
||||
const newBalance = current.balance - amount;
|
||||
|
||||
const updated = await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
version: current.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, userId), eq(balances.version, current.version)))
|
||||
.returning();
|
||||
|
||||
if (updated.length === 0) {
|
||||
throw new ConflictError('Balance was modified concurrently. Please retry.');
|
||||
}
|
||||
|
||||
const [reservation] = await tx
|
||||
.insert(creditReservations)
|
||||
.values({ userId, amount, reason, status: 'reserved' })
|
||||
.returning();
|
||||
|
||||
return {
|
||||
reservationId: reservation.id,
|
||||
balance: newBalance,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async commitReservation(reservationId: string, description?: string) {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [reservation] = await tx
|
||||
.select()
|
||||
.from(creditReservations)
|
||||
.where(eq(creditReservations.id, reservationId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!reservation) throw new NotFoundError('Reservation not found');
|
||||
if (reservation.status !== 'reserved') {
|
||||
throw new BadRequestError(`Cannot commit reservation in status: ${reservation.status}`);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(creditReservations)
|
||||
.set({ status: 'committed', resolvedAt: new Date() })
|
||||
.where(eq(creditReservations.id, reservationId));
|
||||
|
||||
const [balance] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, reservation.userId))
|
||||
.limit(1);
|
||||
|
||||
const balanceAfter = balance?.balance ?? 0;
|
||||
const balanceBefore = balanceAfter + reservation.amount;
|
||||
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
totalSpent: (balance?.totalSpent ?? 0) + reservation.amount,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(balances.userId, reservation.userId));
|
||||
|
||||
const [transaction] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId: reservation.userId,
|
||||
type: 'usage',
|
||||
status: 'completed',
|
||||
amount: -reservation.amount,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
appId: reservation.reason.split(':')[0] || 'mana-research',
|
||||
description: description ?? reservation.reason,
|
||||
metadata: { reservationId: reservation.id },
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { success: true, transactionId: transaction.id };
|
||||
});
|
||||
}
|
||||
|
||||
async refundReservation(reservationId: string) {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [reservation] = await tx
|
||||
.select()
|
||||
.from(creditReservations)
|
||||
.where(eq(creditReservations.id, reservationId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!reservation) throw new NotFoundError('Reservation not found');
|
||||
if (reservation.status !== 'reserved') {
|
||||
throw new BadRequestError(`Cannot refund reservation in status: ${reservation.status}`);
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(creditReservations)
|
||||
.set({ status: 'refunded', resolvedAt: new Date() })
|
||||
.where(eq(creditReservations.id, reservationId));
|
||||
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, reservation.userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!current) throw new NotFoundError('User balance not found');
|
||||
|
||||
const newBalance = current.balance + reservation.amount;
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
version: current.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, reservation.userId), eq(balances.version, current.version)));
|
||||
|
||||
return { success: true, balance: newBalance };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
/**
|
||||
* Gift Code Service — Gift code generation, redemption, cancellation
|
||||
*
|
||||
* Simplified: only 'simple' and 'personalized' gift types.
|
||||
* Each gift is a single-use code (one redeemer gets all credits).
|
||||
*/
|
||||
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import {
|
||||
giftCodes,
|
||||
giftRedemptions,
|
||||
GIFT_CODE_CHARS,
|
||||
GIFT_CODE_LENGTH,
|
||||
GIFT_CODE_RULES,
|
||||
} from '../db/schema/gifts';
|
||||
import { balances, transactions } from '../db/schema/credits';
|
||||
import type { Database } from '../db/connection';
|
||||
import { BadRequestError, NotFoundError } from '../lib/errors';
|
||||
|
||||
interface CreateGiftParams {
|
||||
totalCredits: number;
|
||||
type?: 'simple' | 'personalized';
|
||||
targetEmail?: string;
|
||||
message?: string;
|
||||
expirationDays?: number;
|
||||
}
|
||||
|
||||
export class GiftCodeService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private baseUrl: string
|
||||
) {}
|
||||
|
||||
private generateCode(): string {
|
||||
let code = '';
|
||||
for (let i = 0; i < GIFT_CODE_LENGTH; i++) {
|
||||
code += GIFT_CODE_CHARS[Math.floor(Math.random() * GIFT_CODE_CHARS.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
async createGift(creatorId: string, creatorName: string, params: CreateGiftParams) {
|
||||
const { totalCredits, type = 'simple' } = params;
|
||||
|
||||
if (totalCredits < GIFT_CODE_RULES.minCredits || totalCredits > GIFT_CODE_RULES.maxCredits) {
|
||||
throw new BadRequestError(
|
||||
`Credits must be between ${GIFT_CODE_RULES.minCredits} and ${GIFT_CODE_RULES.maxCredits}`
|
||||
);
|
||||
}
|
||||
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [balance] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, creatorId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!balance || balance.balance < totalCredits) {
|
||||
throw new BadRequestError('Insufficient credits to create gift');
|
||||
}
|
||||
|
||||
// Deduct from creator
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: balance.balance - totalCredits,
|
||||
totalSpent: balance.totalSpent + totalCredits,
|
||||
version: balance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(balances.userId, creatorId), eq(balances.version, balance.version)));
|
||||
|
||||
// Reservation transaction
|
||||
const [reservationTx] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId: creatorId,
|
||||
type: 'gift',
|
||||
status: 'completed',
|
||||
amount: -totalCredits,
|
||||
balanceBefore: balance.balance,
|
||||
balanceAfter: balance.balance - totalCredits,
|
||||
appId: 'gifts',
|
||||
description: `Gift code created: ${totalCredits} credits`,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
const code = this.generateCode();
|
||||
const expiresAt = params.expirationDays
|
||||
? new Date(Date.now() + params.expirationDays * 24 * 60 * 60 * 1000)
|
||||
: new Date(Date.now() + GIFT_CODE_RULES.defaultExpirationDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [gift] = await tx
|
||||
.insert(giftCodes)
|
||||
.values({
|
||||
code,
|
||||
shortUrl: `${this.baseUrl}/g/${code}`,
|
||||
creatorId,
|
||||
creatorName,
|
||||
totalCredits,
|
||||
type,
|
||||
targetEmail: params.targetEmail,
|
||||
message: params.message,
|
||||
expiresAt,
|
||||
reservationTransactionId: reservationTx.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return gift;
|
||||
});
|
||||
}
|
||||
|
||||
async redeemGift(code: string, redeemerId: string, sourceAppId?: string) {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [gift] = await tx
|
||||
.select()
|
||||
.from(giftCodes)
|
||||
.where(eq(giftCodes.code, code.toUpperCase()))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!gift) throw new NotFoundError('Gift code not found');
|
||||
if (gift.status !== 'active') throw new BadRequestError(`Gift code is ${gift.status}`);
|
||||
if (gift.expiresAt && new Date() > gift.expiresAt)
|
||||
throw new BadRequestError('Gift code expired');
|
||||
if (gift.redeemed >= 1) throw new BadRequestError('Gift already claimed');
|
||||
|
||||
// Add credits to redeemer
|
||||
const [balance] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, redeemerId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const balanceBefore = balance?.balance ?? 0;
|
||||
const newBalance = balanceBefore + gift.totalCredits;
|
||||
|
||||
if (balance) {
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalEarned: balance.totalEarned + gift.totalCredits,
|
||||
version: balance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(balances.userId, redeemerId));
|
||||
} else {
|
||||
await tx.insert(balances).values({
|
||||
userId: redeemerId,
|
||||
balance: gift.totalCredits,
|
||||
totalEarned: gift.totalCredits,
|
||||
totalSpent: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Ledger
|
||||
const [creditTx] = await tx
|
||||
.insert(transactions)
|
||||
.values({
|
||||
userId: redeemerId,
|
||||
type: 'gift',
|
||||
status: 'completed',
|
||||
amount: gift.totalCredits,
|
||||
balanceBefore,
|
||||
balanceAfter: newBalance,
|
||||
appId: 'gifts',
|
||||
description: `Gift redeemed: ${gift.totalCredits} credits from ${gift.creatorName || 'someone'}`,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Track redemption
|
||||
await tx.insert(giftRedemptions).values({
|
||||
giftCodeId: gift.id,
|
||||
redeemerUserId: redeemerId,
|
||||
status: 'success',
|
||||
creditsReceived: gift.totalCredits,
|
||||
creditTransactionId: creditTx.id,
|
||||
sourceAppId,
|
||||
});
|
||||
|
||||
// Mark gift as depleted
|
||||
await tx
|
||||
.update(giftCodes)
|
||||
.set({ redeemed: 1, status: 'depleted', updatedAt: new Date() })
|
||||
.where(eq(giftCodes.id, gift.id));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
creditsReceived: gift.totalCredits,
|
||||
message: gift.message,
|
||||
creatorName: gift.creatorName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getGiftInfo(code: string) {
|
||||
const [gift] = await this.db
|
||||
.select()
|
||||
.from(giftCodes)
|
||||
.where(eq(giftCodes.code, code.toUpperCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!gift) throw new NotFoundError('Gift code not found');
|
||||
|
||||
return {
|
||||
code: gift.code,
|
||||
type: gift.type,
|
||||
status: gift.status,
|
||||
totalCredits: gift.totalCredits,
|
||||
redeemed: gift.redeemed > 0,
|
||||
message: gift.message,
|
||||
creatorName: gift.creatorName,
|
||||
expiresAt: gift.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getCreatedGifts(userId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(giftCodes)
|
||||
.where(eq(giftCodes.creatorId, userId))
|
||||
.orderBy(desc(giftCodes.createdAt));
|
||||
}
|
||||
|
||||
async getReceivedGifts(userId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(giftRedemptions)
|
||||
.where(eq(giftRedemptions.redeemerUserId, userId))
|
||||
.orderBy(desc(giftRedemptions.createdAt));
|
||||
}
|
||||
|
||||
async cancelGift(giftId: string, userId: string) {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [gift] = await tx
|
||||
.select()
|
||||
.from(giftCodes)
|
||||
.where(and(eq(giftCodes.id, giftId), eq(giftCodes.creatorId, userId)))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!gift) throw new NotFoundError('Gift not found');
|
||||
if (gift.status !== 'active') throw new BadRequestError('Only active gifts can be cancelled');
|
||||
|
||||
// Only refund if not yet redeemed
|
||||
const refundAmount = gift.redeemed === 0 ? gift.totalCredits : 0;
|
||||
|
||||
if (refundAmount > 0) {
|
||||
const [balance] = await tx
|
||||
.select()
|
||||
.from(balances)
|
||||
.where(eq(balances.userId, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (balance) {
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: balance.balance + refundAmount,
|
||||
totalEarned: balance.totalEarned + refundAmount,
|
||||
version: balance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(balances.userId, userId));
|
||||
|
||||
await tx.insert(transactions).values({
|
||||
userId,
|
||||
type: 'refund',
|
||||
status: 'completed',
|
||||
amount: refundAmount,
|
||||
balanceBefore: balance.balance,
|
||||
balanceAfter: balance.balance + refundAmount,
|
||||
appId: 'gifts',
|
||||
description: `Gift cancelled, ${refundAmount} credits refunded`,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(giftCodes)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(giftCodes.id, giftId));
|
||||
|
||||
return { success: true, refundedCredits: refundAmount };
|
||||
});
|
||||
}
|
||||
|
||||
/** Auto-redeem pending gifts for a newly registered user */
|
||||
async redeemPendingForUser(userId: string, email: string) {
|
||||
const pendingGifts = await this.db
|
||||
.select()
|
||||
.from(giftCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(giftCodes.targetEmail, email),
|
||||
eq(giftCodes.status, 'active'),
|
||||
eq(giftCodes.type, 'personalized')
|
||||
)
|
||||
);
|
||||
|
||||
let totalRedeemed = 0;
|
||||
for (const gift of pendingGifts) {
|
||||
try {
|
||||
const result = await this.redeemGift(gift.code, userId, 'auto-registration');
|
||||
totalRedeemed += result.creditsReceived;
|
||||
} catch {
|
||||
// Skip failed redemptions
|
||||
}
|
||||
}
|
||||
return { redeemed: pendingGifts.length, totalCredits: totalRedeemed };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
/**
|
||||
* Stripe Service — Payment integration
|
||||
*
|
||||
* Handles customer management, payment intents, checkout sessions,
|
||||
* and webhook signature verification.
|
||||
*/
|
||||
|
||||
import Stripe from 'stripe';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { stripeCustomers } from '../db/schema/credits';
|
||||
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 (missing STRIPE_SECRET_KEY)');
|
||||
return this.stripe;
|
||||
}
|
||||
|
||||
async getOrCreateCustomer(userId: string, email: string): Promise<string> {
|
||||
// Check existing mapping
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(stripeCustomers)
|
||||
.where(eq(stripeCustomers.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) return existing.stripeCustomerId;
|
||||
|
||||
// Create new Stripe customer
|
||||
const customer = await this.getStripe().customers.create({
|
||||
email,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
await this.db.insert(stripeCustomers).values({
|
||||
userId,
|
||||
stripeCustomerId: customer.id,
|
||||
email,
|
||||
});
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async createPaymentIntent(
|
||||
customerId: string,
|
||||
amountCents: number,
|
||||
metadata: Record<string, string>
|
||||
) {
|
||||
return this.getStripe().paymentIntents.create({
|
||||
amount: amountCents,
|
||||
currency: 'eur',
|
||||
customer: customerId,
|
||||
payment_method_types: ['card', 'sepa_debit'],
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
async createCheckoutSession(params: {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
quantity: number;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
metadata: Record<string, string>;
|
||||
}) {
|
||||
return this.getStripe().checkout.sessions.create({
|
||||
customer: params.customerId,
|
||||
mode: 'payment',
|
||||
line_items: [{ price: params.priceId, quantity: params.quantity }],
|
||||
success_url: params.successUrl,
|
||||
cancel_url: params.cancelUrl,
|
||||
metadata: params.metadata,
|
||||
expires_after: 86400, // 24 hours
|
||||
});
|
||||
}
|
||||
|
||||
verifyWebhookSignature(body: string | Buffer, signature: string, secret: string): Stripe.Event {
|
||||
return this.getStripe().webhooks.constructEvent(body, signature, secret);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
/**
|
||||
* Sync Billing Service — manages sync subscriptions and recurring charges
|
||||
*/
|
||||
|
||||
import { eq, and, lte } from 'drizzle-orm';
|
||||
import { syncSubscriptions } from '../db/schema/sync';
|
||||
import type { Database } from '../db/connection';
|
||||
import type { CreditsService } from './credits';
|
||||
import { BadRequestError, NotFoundError, InsufficientCreditsError } from '../lib/errors';
|
||||
|
||||
type BillingInterval = 'monthly' | 'quarterly' | 'yearly';
|
||||
|
||||
const MANA_NOTIFY_URL = () => process.env.MANA_NOTIFY_URL || 'http://localhost:3040';
|
||||
const SERVICE_KEY = () => process.env.MANA_SERVICE_KEY || '';
|
||||
|
||||
async function sendPauseNotification(userId: string): Promise<void> {
|
||||
const key = SERVICE_KEY();
|
||||
if (!key) return;
|
||||
|
||||
try {
|
||||
await fetch(`${MANA_NOTIFY_URL()}/api/v1/notifications/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Key': key,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel: 'email',
|
||||
appId: 'sync',
|
||||
userId,
|
||||
subject: 'Cloud Sync pausiert',
|
||||
body: 'Dein Cloud Sync wurde pausiert, weil deine Credits nicht ausreichen. Lade Credits auf, um die Synchronisation fortzusetzen.',
|
||||
data: { type: 'sync-paused', resumeUrl: 'https://mana.how/settings/sync' },
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[sync-billing] Failed to send pause notification for ${userId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const SYNC_PRICES: Record<BillingInterval, number> = {
|
||||
monthly: 30,
|
||||
quarterly: 90,
|
||||
yearly: 360,
|
||||
};
|
||||
|
||||
function getNextChargeDate(from: Date, interval: BillingInterval): Date {
|
||||
const next = new Date(from);
|
||||
switch (interval) {
|
||||
case 'monthly':
|
||||
next.setMonth(next.getMonth() + 1);
|
||||
break;
|
||||
case 'quarterly':
|
||||
next.setMonth(next.getMonth() + 3);
|
||||
break;
|
||||
case 'yearly':
|
||||
next.setFullYear(next.getFullYear() + 1);
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export class SyncBillingService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private creditsService: CreditsService
|
||||
) {}
|
||||
|
||||
async getSyncStatus(userId: string) {
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.from(syncSubscriptions)
|
||||
.where(eq(syncSubscriptions.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!sub) {
|
||||
return {
|
||||
active: false,
|
||||
interval: 'monthly' as BillingInterval,
|
||||
nextChargeAt: null,
|
||||
pausedAt: null,
|
||||
gifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
active: sub.active,
|
||||
interval: sub.billingInterval as BillingInterval,
|
||||
nextChargeAt: sub.nextChargeAt?.toISOString() ?? null,
|
||||
pausedAt: sub.pausedAt?.toISOString() ?? null,
|
||||
gifted: sub.isGifted,
|
||||
};
|
||||
}
|
||||
|
||||
async activateSync(userId: string, interval: BillingInterval = 'monthly') {
|
||||
const amount = SYNC_PRICES[interval];
|
||||
const now = new Date();
|
||||
|
||||
// Check if already active
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(syncSubscriptions)
|
||||
.where(eq(syncSubscriptions.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing?.isGifted) {
|
||||
throw new BadRequestError('Sync is already gifted — no activation needed');
|
||||
}
|
||||
|
||||
if (existing?.active) {
|
||||
throw new BadRequestError('Sync is already active');
|
||||
}
|
||||
|
||||
// Charge credits
|
||||
await this.creditsService.useCredits(userId, {
|
||||
amount,
|
||||
appId: 'sync',
|
||||
description: `Cloud Sync activated (${interval})`,
|
||||
metadata: { interval, type: 'sync_subscription' },
|
||||
});
|
||||
|
||||
const nextChargeAt = getNextChargeDate(now, interval);
|
||||
|
||||
if (existing) {
|
||||
// Reactivate existing subscription
|
||||
await this.db
|
||||
.update(syncSubscriptions)
|
||||
.set({
|
||||
active: true,
|
||||
billingInterval: interval,
|
||||
amountCharged: amount,
|
||||
activatedAt: now,
|
||||
nextChargeAt,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(syncSubscriptions.userId, userId));
|
||||
} else {
|
||||
// Create new subscription
|
||||
await this.db.insert(syncSubscriptions).values({
|
||||
userId,
|
||||
active: true,
|
||||
billingInterval: interval,
|
||||
amountCharged: amount,
|
||||
activatedAt: now,
|
||||
nextChargeAt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
active: true,
|
||||
interval,
|
||||
nextChargeAt: nextChargeAt.toISOString(),
|
||||
amountCharged: amount,
|
||||
};
|
||||
}
|
||||
|
||||
async deactivateSync(userId: string) {
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.from(syncSubscriptions)
|
||||
.where(eq(syncSubscriptions.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!sub || !sub.active) {
|
||||
throw new BadRequestError('Sync is not active');
|
||||
}
|
||||
|
||||
if (sub.isGifted) {
|
||||
throw new BadRequestError('Sync is gifted — contact support to revoke');
|
||||
}
|
||||
|
||||
await this.db
|
||||
.update(syncSubscriptions)
|
||||
.set({
|
||||
active: false,
|
||||
nextChargeAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(syncSubscriptions.userId, userId));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async changeBillingInterval(userId: string, newInterval: BillingInterval) {
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.from(syncSubscriptions)
|
||||
.where(eq(syncSubscriptions.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!sub || !sub.active) {
|
||||
throw new BadRequestError('Sync is not active');
|
||||
}
|
||||
|
||||
const newAmount = SYNC_PRICES[newInterval];
|
||||
|
||||
// Change takes effect at next billing cycle
|
||||
await this.db
|
||||
.update(syncSubscriptions)
|
||||
.set({
|
||||
billingInterval: newInterval,
|
||||
amountCharged: newAmount,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(syncSubscriptions.userId, userId));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
interval: newInterval,
|
||||
amountCharged: newAmount,
|
||||
effectiveAt: sub.nextChargeAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge all due sync subscriptions. Called by cron job (daily).
|
||||
* Returns summary of charges, pauses, and errors.
|
||||
*/
|
||||
async chargeRecurring() {
|
||||
const now = new Date();
|
||||
|
||||
// Gifted subscriptions are skipped — they never get charged.
|
||||
const dueSubscriptions = await this.db
|
||||
.select()
|
||||
.from(syncSubscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(syncSubscriptions.active, true),
|
||||
eq(syncSubscriptions.isGifted, false),
|
||||
lte(syncSubscriptions.nextChargeAt, now)
|
||||
)
|
||||
);
|
||||
|
||||
let charged = 0;
|
||||
let paused = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const sub of dueSubscriptions) {
|
||||
try {
|
||||
await this.creditsService.useCredits(sub.userId, {
|
||||
amount: sub.amountCharged,
|
||||
appId: 'sync',
|
||||
description: `Cloud Sync renewal (${sub.billingInterval})`,
|
||||
metadata: { interval: sub.billingInterval, type: 'sync_renewal' },
|
||||
});
|
||||
|
||||
// Update next charge date
|
||||
const nextChargeAt = getNextChargeDate(now, sub.billingInterval as BillingInterval);
|
||||
await this.db
|
||||
.update(syncSubscriptions)
|
||||
.set({ nextChargeAt, updatedAt: now })
|
||||
.where(eq(syncSubscriptions.userId, sub.userId));
|
||||
|
||||
charged++;
|
||||
} catch (error) {
|
||||
if (error instanceof InsufficientCreditsError) {
|
||||
// Pause subscription
|
||||
await this.db
|
||||
.update(syncSubscriptions)
|
||||
.set({
|
||||
active: false,
|
||||
pausedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(syncSubscriptions.userId, sub.userId));
|
||||
|
||||
paused++;
|
||||
sendPauseNotification(sub.userId).catch(() => {});
|
||||
} else {
|
||||
errors++;
|
||||
console.error(`[sync-billing] Failed to charge user ${sub.userId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { charged, paused, errors, total: dueSubscriptions.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant sync as a gift — no credits charged, no recurring billing.
|
||||
* Idempotent: re-granting refreshes the giftedAt/giftedBy fields.
|
||||
*/
|
||||
async grantSyncGift(userId: string, grantedBy?: string) {
|
||||
const now = new Date();
|
||||
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(syncSubscriptions)
|
||||
.where(eq(syncSubscriptions.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.db
|
||||
.update(syncSubscriptions)
|
||||
.set({
|
||||
active: true,
|
||||
isGifted: true,
|
||||
giftedBy: grantedBy ?? null,
|
||||
giftedAt: now,
|
||||
activatedAt: existing.activatedAt ?? now,
|
||||
nextChargeAt: null,
|
||||
pausedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(syncSubscriptions.userId, userId));
|
||||
} else {
|
||||
await this.db.insert(syncSubscriptions).values({
|
||||
userId,
|
||||
active: true,
|
||||
isGifted: true,
|
||||
giftedBy: grantedBy ?? null,
|
||||
giftedAt: now,
|
||||
activatedAt: now,
|
||||
nextChargeAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, userId, gifted: true, active: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a sync gift. Deactivates sync and clears the gift flag.
|
||||
* If the user wants sync back, they must activate normally (paying credits).
|
||||
*/
|
||||
async revokeSyncGift(userId: string) {
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.from(syncSubscriptions)
|
||||
.where(eq(syncSubscriptions.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!sub) {
|
||||
throw new NotFoundError('No sync subscription found for user');
|
||||
}
|
||||
|
||||
if (!sub.isGifted) {
|
||||
throw new BadRequestError('Sync is not gifted — nothing to revoke');
|
||||
}
|
||||
|
||||
await this.db
|
||||
.update(syncSubscriptions)
|
||||
.set({
|
||||
active: false,
|
||||
isGifted: false,
|
||||
giftedBy: null,
|
||||
giftedAt: null,
|
||||
nextChargeAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(syncSubscriptions.userId, userId));
|
||||
|
||||
return { success: true, userId, gifted: false, active: false };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue