Migrate billing backend from Django to Express + Apollo Server + Prisma
All checks were successful
Build Docker Image / build (push) Successful in 1m44s
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:
66
src/auth.ts
Normal file
66
src/auth.ts
Normal 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
3
src/db.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
export const prisma = new PrismaClient()
|
||||
64
src/index.ts
Normal file
64
src/index.ts
Normal 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
138
src/schemas/m2m.ts
Normal 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
90
src/schemas/team.ts
Normal 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
72
src/tigerbeetle.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user