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:
Till JS 2026-05-08 18:53:56 +02:00
parent fcc36eadcb
commit af3f21a179
29 changed files with 0 additions and 2704 deletions

View file

@ -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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
export * from './credits';
export * from './gifts';
export * from './sync';
export * from './reservations';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
}

View file

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

View file

@ -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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 };
}
}

View file

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

View file

@ -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 };
}
}

View file

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