Migrate billing backend from Django to Express + Apollo Server + Prisma
All checks were successful
Build Docker Image / build (push) Successful in 1m44s

Replace Python/Django/Graphene with TypeScript/Express/Apollo Server.
Same 2 endpoints (team/m2m), same JWT auth, same TigerBeetle integration.
Prisma ORM replaces Django ORM for Account/OperationCode/ServiceAccount.
This commit is contained in:
Ruslan Bakiev
2026-03-09 09:13:07 +07:00
parent 5ce3acf8b0
commit 2d96afabec
49 changed files with 4356 additions and 2968 deletions

66
src/auth.ts Normal file
View File

@@ -0,0 +1,66 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
import { GraphQLError } from 'graphql'
import type { Request } from 'express'
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
const LOGTO_BILLING_AUDIENCE = process.env.LOGTO_BILLING_AUDIENCE || 'https://billing.optovia.ru'
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
export interface AuthContext {
userId?: string
teamUuid?: string
scopes: string[]
isM2M?: boolean
}
function getBearerToken(req: Request): string {
const auth = req.headers.authorization || ''
if (!auth.startsWith('Bearer ')) {
throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
}
const token = auth.slice(7)
if (!token || token === 'undefined') {
throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
}
return token
}
function scopesFromPayload(payload: JWTPayload): string[] {
const scope = payload.scope
if (!scope) return []
if (typeof scope === 'string') return scope.split(' ')
if (Array.isArray(scope)) return scope as string[]
return []
}
export async function m2mContext(): Promise<AuthContext> {
return { scopes: [], isM2M: true }
}
export async function teamContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req)
const { payload } = await jwtVerify(token, jwks, {
issuer: LOGTO_ISSUER,
audience: LOGTO_BILLING_AUDIENCE,
})
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
const scopes = scopesFromPayload(payload)
if (!teamUuid || !scopes.includes('teams:member')) {
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
}
return { userId: payload.sub, teamUuid, scopes }
}
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
const missing = required.filter(s => !ctx.scopes.includes(s))
if (missing.length > 0) {
throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, {
extensions: { code: 'FORBIDDEN' },
})
}
}

3
src/db.ts Normal file
View File

@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()

64
src/index.ts Normal file
View File

@@ -0,0 +1,64 @@
import express from 'express'
import cors from 'cors'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import * as Sentry from '@sentry/node'
import { teamTypeDefs, teamResolvers } from './schemas/team.js'
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
import { m2mContext, teamContext, type AuthContext } from './auth.js'
const PORT = parseInt(process.env.PORT || '8000', 10)
const SENTRY_DSN = process.env.SENTRY_DSN || ''
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 0.01,
release: process.env.RELEASE_VERSION || '1.0.0',
environment: process.env.ENVIRONMENT || 'production',
})
}
const app = express()
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
const teamServer = new ApolloServer<AuthContext>({
typeDefs: teamTypeDefs,
resolvers: teamResolvers,
introspection: true,
})
const m2mServer = new ApolloServer<AuthContext>({
typeDefs: m2mTypeDefs,
resolvers: m2mResolvers,
introspection: true,
})
await Promise.all([teamServer.start(), m2mServer.start()])
app.use(
'/graphql/team',
express.json(),
expressMiddleware(teamServer, {
context: async ({ req }) => teamContext(req as unknown as import('express').Request),
}) as unknown as express.RequestHandler,
)
app.use(
'/graphql/m2m',
express.json(),
expressMiddleware(m2mServer, {
context: async () => m2mContext(),
}) as unknown as express.RequestHandler,
)
app.get('/health', (_, res) => {
res.json({ status: 'ok' })
})
app.listen(PORT, '0.0.0.0', () => {
console.log(`Billing server ready on port ${PORT}`)
console.log(` /graphql/team - team access token auth`)
console.log(` /graphql/m2m - internal services (no auth)`)
})

138
src/schemas/m2m.ts Normal file
View File

@@ -0,0 +1,138 @@
import { GraphQLError } from 'graphql'
import { randomUUID } from 'crypto'
import { prisma } from '../db.js'
import {
lookupAccounts,
createAccounts,
createTransfers,
Account,
Transfer,
uuidToBigInt,
} from '../tigerbeetle.js'
import type { AuthContext } from '../auth.js'
const LEDGER = 1
const DEFAULT_ACCOUNT_CODE = 100
export const m2mTypeDefs = `#graphql
type OperationCode {
code: Int!
name: String!
description: String
}
type ServiceAccount {
slug: String!
accountUuid: String!
}
input CreateTransactionInput {
fromUuid: String!
toUuid: String!
amount: Int!
code: Int!
}
type CreateTransactionResult {
success: Boolean!
message: String
transferId: String
}
type Query {
operationCodes: [OperationCode]
serviceAccounts: [ServiceAccount]
}
type Mutation {
createTransaction(input: CreateTransactionInput!): CreateTransactionResult
}
`
export const m2mResolvers = {
Query: {
operationCodes: async () => {
return prisma.operationCode.findMany({ orderBy: { code: 'asc' } })
},
serviceAccounts: async () => {
const sas = await prisma.serviceAccount.findMany({ include: { account: true } })
return sas.map(sa => ({ slug: sa.slug, accountUuid: sa.accountUuid }))
},
},
Mutation: {
createTransaction: async (
_: unknown,
args: { input: { fromUuid: string; toUuid: string; amount: number; code: number } },
_ctx: AuthContext,
) => {
const { fromUuid, toUuid, amount, code } = args.input
// Validate UUIDs
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (!uuidRegex.test(fromUuid) || !uuidRegex.test(toUuid)) {
return { success: false, message: 'Invalid UUID format', transferId: null }
}
// Ensure local Account records exist
for (const uuid of [fromUuid, toUuid]) {
const exists = await prisma.account.findUnique({ where: { uuid } })
if (!exists) {
await prisma.account.create({
data: { uuid, name: `Team ${uuid.slice(0, 8)}`, accountType: 'USER' },
})
}
// Check if account exists in TigerBeetle, create if not
const tbId = uuidToBigInt(uuid)
const tbAccounts = await lookupAccounts([tbId])
if (tbAccounts.length === 0) {
const account: Account = {
id: tbId,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
reserved: 0,
ledger: LEDGER,
code: DEFAULT_ACCOUNT_CODE,
flags: 0,
timestamp: 0n,
}
const errors = await createAccounts([account])
if (errors.length > 0) {
return { success: false, message: `Failed to create account in TigerBeetle: ${errors}`, transferId: null }
}
}
}
// Execute transfer
const transferId = randomUUID()
const transfer: Transfer = {
id: uuidToBigInt(transferId),
debit_account_id: uuidToBigInt(fromUuid),
credit_account_id: uuidToBigInt(toUuid),
amount: BigInt(amount),
pending_id: 0n,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
ledger: LEDGER,
code,
flags: 0,
timestamp: 0n,
}
const errors = await createTransfers([transfer])
if (errors.length > 0) {
return { success: false, message: `Transfer failed: ${errors}`, transferId: null }
}
console.log(`Transfer ${transferId} completed: ${fromUuid} -> ${toUuid}, amount=${amount}`)
return { success: true, message: 'Transaction completed', transferId }
},
},
}

90
src/schemas/team.ts Normal file
View File

@@ -0,0 +1,90 @@
import { GraphQLError } from 'graphql'
import { requireScopes, type AuthContext } from '../auth.js'
import { lookupAccounts, getAccountTransfers, uuidToBigInt, bigIntToUuid } from '../tigerbeetle.js'
import { prisma } from '../db.js'
export const teamTypeDefs = `#graphql
type TeamBalance {
balance: Float!
creditsPosted: Float!
debitsPosted: Float!
exists: Boolean!
}
type TeamTransaction {
id: String!
amount: Float!
timestamp: Float
code: Int
codeName: String
direction: String!
counterpartyUuid: String
}
type Query {
teamBalance: TeamBalance
teamTransactions(limit: Int = 50): [TeamTransaction]
}
`
export const teamResolvers = {
Query: {
teamBalance: async (_: unknown, __: unknown, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) throw new GraphQLError('Team UUID not found in context')
try {
const tbAccountId = uuidToBigInt(ctx.teamUuid)
const accounts = await lookupAccounts([tbAccountId])
if (accounts.length === 0) {
return { balance: 0, creditsPosted: 0, debitsPosted: 0, exists: false }
}
const account = accounts[0]
const creditsPosted = Number(account.credits_posted)
const debitsPosted = Number(account.debits_posted)
return {
balance: creditsPosted - debitsPosted,
creditsPosted,
debitsPosted,
exists: true,
}
} catch (e) {
if (e instanceof GraphQLError) throw e
console.error('Error fetching team balance:', e)
throw new GraphQLError('Failed to fetch team balance')
}
},
teamTransactions: async (_: unknown, args: { limit: number }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) throw new GraphQLError('Team UUID not found in context')
try {
const tbAccountId = uuidToBigInt(ctx.teamUuid)
const transfers = await getAccountTransfers(tbAccountId, args.limit)
const codes = await prisma.operationCode.findMany()
const codeMap = new Map(codes.map(c => [c.code, c.name]))
return transfers.map(t => {
const isCredit = t.credit_account_id === tbAccountId
return {
id: bigIntToUuid(t.id),
amount: Number(t.amount),
timestamp: Number(t.timestamp),
code: t.code,
codeName: codeMap.get(t.code) ?? null,
direction: isCredit ? 'credit' : 'debit',
counterpartyUuid: bigIntToUuid(isCredit ? t.debit_account_id : t.credit_account_id),
}
})
} catch (e) {
if (e instanceof GraphQLError) throw e
console.error('Error fetching team transactions:', e)
throw new GraphQLError('Failed to fetch team transactions')
}
},
},
}

72
src/tigerbeetle.ts Normal file
View File

@@ -0,0 +1,72 @@
import {
createClient,
Account,
Transfer,
AccountFilter,
AccountFilterFlags,
type Client,
} from 'tigerbeetle-node'
const TB_CLUSTER_ID = parseInt(process.env.TB_CLUSTER_ID || '0', 10)
const TB_ADDRESS = process.env.TB_ADDRESS || '127.0.0.1:3000'
let client: Client | null = null
async function ensureConnected(): Promise<Client> {
if (client) return client
client = createClient({
cluster_id: BigInt(TB_CLUSTER_ID),
replica_addresses: [TB_ADDRESS],
})
console.log(`Connected to TigerBeetle cluster ${TB_CLUSTER_ID} at ${TB_ADDRESS}`)
return client
}
function uuidToBigInt(uuid: string): bigint {
const hex = uuid.replace(/-/g, '')
return BigInt('0x' + hex)
}
function bigIntToUuid(n: bigint): string {
const hex = n.toString(16).padStart(32, '0')
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-')
}
export async function lookupAccounts(accountIds: bigint[]) {
const tb = await ensureConnected()
return tb.lookupAccounts(accountIds)
}
export async function createAccounts(accounts: Account[]) {
const tb = await ensureConnected()
return tb.createAccounts(accounts)
}
export async function createTransfers(transfers: Transfer[]) {
const tb = await ensureConnected()
return tb.createTransfers(transfers)
}
export async function getAccountTransfers(accountId: bigint, limit = 50) {
const tb = await ensureConnected()
const filter: AccountFilter = {
account_id: accountId,
timestamp_min: 0n,
timestamp_max: 0n,
limit,
flags: AccountFilterFlags.credits | AccountFilterFlags.debits | AccountFilterFlags.reversed,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
code: 0,
}
return tb.getAccountTransfers(filter)
}
export { Account, Transfer, uuidToBigInt, bigIntToUuid }