feat(finance): BTC basket flows, client scoping, and jewelry-box store
- Finance API: baskets, holdings, rebalances, deposits, bridge withdrawals, vault checks. - Schemas: btc-basket; api-client finance types; workspace lockfile update. - Vitest config for finance service; expanded tests. Made-with: Cursor
This commit is contained in:
@@ -9,11 +9,14 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"type-check": "tsc --noEmit"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/swagger": "^8.13.0",
|
||||
"@fastify/swagger-ui": "^2.0.0",
|
||||
"@the-order/database": "workspace:*",
|
||||
"@the-order/payment-gateway": "workspace:^",
|
||||
"@the-order/schemas": "workspace:*",
|
||||
"@the-order/shared": "workspace:*",
|
||||
@@ -23,6 +26,7 @@
|
||||
"@types/node": "^20.10.6",
|
||||
"eslint": "^9.17.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,324 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import { createApiHelpers } from '@the-order/test-utils';
|
||||
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createHmac } from 'crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
describe('Finance Service', () => {
|
||||
let app: FastifyInstance;
|
||||
let api: ReturnType<typeof createApiHelpers>;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(async () => {
|
||||
app = Fastify({
|
||||
logger: false,
|
||||
});
|
||||
function setTestEnv(): void {
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.PORT = '4003';
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/the_order_test';
|
||||
process.env.STORAGE_BUCKET = 'test-bucket';
|
||||
process.env.KMS_KEY_ID = 'test-kms-key';
|
||||
process.env.JWT_SECRET = 'test-jwt-secret-which-is-long-enough-1234567890';
|
||||
}
|
||||
|
||||
app.get('/health', async () => {
|
||||
return { status: 'ok', service: 'finance' };
|
||||
});
|
||||
function base64Url(value: string): string {
|
||||
return Buffer.from(value).toString('base64url');
|
||||
}
|
||||
|
||||
await app.ready();
|
||||
api = createApiHelpers(app);
|
||||
});
|
||||
function createJwt(payload: Record<string, unknown>): string {
|
||||
const header = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
||||
const body = base64Url(JSON.stringify(payload));
|
||||
const signature = createHmac('sha256', process.env.JWT_SECRET as string)
|
||||
.update(`${header}.${body}`)
|
||||
.digest('base64url');
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
let createServer: (typeof import('./index'))['createServer'];
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('should return health status', async () => {
|
||||
const response = await api.get('/health');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('status');
|
||||
expect(response.body).toHaveProperty('service', 'finance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /ledger/entry', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await api.post('/ledger/entry', {
|
||||
accountId: 'test-account',
|
||||
type: 'debit',
|
||||
amount: 100,
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
expect([401, 500]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /payments', () => {
|
||||
it('should require authentication', async () => {
|
||||
const response = await api.post('/payments', {
|
||||
amount: 100,
|
||||
currency: 'USD',
|
||||
paymentMethod: 'credit_card',
|
||||
});
|
||||
|
||||
expect([401, 500]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
beforeAll(async () => {
|
||||
setTestEnv();
|
||||
({ createServer } = await import('./index'));
|
||||
});
|
||||
|
||||
describe('Finance Service', () => {
|
||||
let server: FastifyInstance;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await server.close();
|
||||
}
|
||||
process.env = { ...originalEnv, ...process.env };
|
||||
});
|
||||
|
||||
it('returns service health', async () => {
|
||||
server = await createServer({ disableSwagger: true });
|
||||
await server.ready();
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/health',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
service: 'finance',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a basket-backed BTC deposit and exposes holdings plus rebalance plan', async () => {
|
||||
server = await createServer({ disableSwagger: true });
|
||||
await server.ready();
|
||||
|
||||
const token = createJwt({
|
||||
id: 'client-1',
|
||||
roles: ['client', 'finance'],
|
||||
});
|
||||
|
||||
const depositResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/btc-deposits',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-1',
|
||||
mandateName: 'Solace BTC jewelry box',
|
||||
chain138VaultAddress: '0x1111111111111111111111111111111111111111',
|
||||
allocations: [
|
||||
{ symbol: 'cBTC', targetWeightBps: 4000 },
|
||||
{ symbol: 'cUSDT', targetWeightBps: 3500 },
|
||||
{ symbol: 'cXAUC', targetWeightBps: 2500 },
|
||||
],
|
||||
expectedAmountSats: 250000000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(depositResponse.statusCode).toBe(201);
|
||||
const depositPayload = depositResponse.json() as {
|
||||
deposit: { id: string; basketId: string; status: string; confirmationsRequired: number; depositAddress: string };
|
||||
basket: { id: string };
|
||||
createdBasket: boolean;
|
||||
};
|
||||
|
||||
expect(depositPayload.createdBasket).toBe(true);
|
||||
expect(depositPayload.deposit.status).toBe('instruction_created');
|
||||
expect(depositPayload.deposit.confirmationsRequired).toBe(6);
|
||||
expect(depositPayload.deposit.depositAddress.startsWith('bc1q')).toBe(true);
|
||||
expect(depositPayload.deposit.basketId).toBe(depositPayload.basket.id);
|
||||
|
||||
const holdingsResponse = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/holdings?clientId=client-1&basketId=${depositPayload.basket.id}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(holdingsResponse.statusCode).toBe(200);
|
||||
const holdingsPayload = holdingsResponse.json() as {
|
||||
holdings: Array<{ symbol: string; allocationWeightBps: number; status: string }>;
|
||||
};
|
||||
expect(holdingsPayload.holdings).toHaveLength(3);
|
||||
expect(holdingsPayload.holdings[1]).toMatchObject({
|
||||
symbol: 'cUSDT',
|
||||
allocationWeightBps: 3500,
|
||||
status: 'pending_funding',
|
||||
});
|
||||
|
||||
const rebalancesResponse = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/rebalances?basketId=${depositPayload.basket.id}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rebalancesResponse.statusCode).toBe(200);
|
||||
const rebalancesPayload = rebalancesResponse.json() as {
|
||||
rebalances: Array<{ sourceSymbol: string; targetSymbols: string[]; status: string }>;
|
||||
};
|
||||
expect(rebalancesPayload.rebalances).toHaveLength(1);
|
||||
expect(rebalancesPayload.rebalances[0]).toMatchObject({
|
||||
sourceSymbol: 'cBTC',
|
||||
status: 'planned',
|
||||
});
|
||||
expect(rebalancesPayload.rebalances[0].targetSymbols).toEqual(['cUSDT', 'cXAUC']);
|
||||
});
|
||||
|
||||
it('defaults bridge withdrawals to cBTC -> cWBTC', async () => {
|
||||
server = await createServer({ disableSwagger: true });
|
||||
await server.ready();
|
||||
|
||||
const token = createJwt({
|
||||
id: 'client-2',
|
||||
roles: ['client'],
|
||||
});
|
||||
|
||||
const basketResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/baskets',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-2',
|
||||
mandateName: 'Client 2 basket',
|
||||
chain138VaultAddress: '0x2222222222222222222222222222222222222222',
|
||||
allocations: [{ symbol: 'cBTC', targetWeightBps: 10000 }],
|
||||
},
|
||||
});
|
||||
|
||||
const basketId = (basketResponse.json() as { basket: { id: string } }).basket.id;
|
||||
|
||||
const withdrawalResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/withdrawals/bridge',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-2',
|
||||
basketId,
|
||||
destinationChainId: 1,
|
||||
destinationAddress: '0x3333333333333333333333333333333333333333',
|
||||
amount: '125000000',
|
||||
},
|
||||
});
|
||||
|
||||
expect(withdrawalResponse.statusCode).toBe(201);
|
||||
expect(withdrawalResponse.json()).toMatchObject({
|
||||
withdrawal: {
|
||||
sourceSymbol: 'cBTC',
|
||||
destinationSymbol: 'cWBTC',
|
||||
destinationChainId: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects cross-client access to another client basket', async () => {
|
||||
server = await createServer({ disableSwagger: true });
|
||||
await server.ready();
|
||||
|
||||
const ownerToken = createJwt({
|
||||
id: 'client-owner',
|
||||
roles: ['client'],
|
||||
});
|
||||
const otherClientToken = createJwt({
|
||||
id: 'client-other',
|
||||
roles: ['client'],
|
||||
});
|
||||
|
||||
const basketResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/baskets',
|
||||
headers: {
|
||||
authorization: `Bearer ${ownerToken}`,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-owner',
|
||||
mandateName: 'Owner basket',
|
||||
chain138VaultAddress: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
allocations: [{ symbol: 'cBTC', targetWeightBps: 10000 }],
|
||||
},
|
||||
});
|
||||
|
||||
const basketId = (basketResponse.json() as { basket: { id: string } }).basket.id;
|
||||
|
||||
const holdingsResponse = await server.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/holdings?basketId=${basketId}`,
|
||||
headers: {
|
||||
authorization: `Bearer ${otherClientToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(holdingsResponse.statusCode).toBe(403);
|
||||
|
||||
const withdrawalResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/withdrawals/bridge',
|
||||
headers: {
|
||||
authorization: `Bearer ${otherClientToken}`,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-other',
|
||||
basketId,
|
||||
destinationChainId: 1,
|
||||
destinationAddress: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
amount: '1000',
|
||||
},
|
||||
});
|
||||
|
||||
expect(withdrawalResponse.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects unknown basket ids and mismatched vault addresses on deposit creation', async () => {
|
||||
server = await createServer({ disableSwagger: true });
|
||||
await server.ready();
|
||||
|
||||
const token = createJwt({
|
||||
id: 'client-3',
|
||||
roles: ['client'],
|
||||
});
|
||||
|
||||
const unknownBasketResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/btc-deposits',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-3',
|
||||
basketId: '11111111-1111-4111-8111-111111111111',
|
||||
chain138VaultAddress: '0xcccccccccccccccccccccccccccccccccccccccc',
|
||||
},
|
||||
});
|
||||
|
||||
expect(unknownBasketResponse.statusCode).toBe(404);
|
||||
|
||||
const basketResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/baskets',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-3',
|
||||
mandateName: 'Vault match basket',
|
||||
chain138VaultAddress: '0xdddddddddddddddddddddddddddddddddddddddd',
|
||||
allocations: [{ symbol: 'cBTC', targetWeightBps: 10000 }],
|
||||
},
|
||||
});
|
||||
|
||||
const basketId = (basketResponse.json() as { basket: { id: string } }).basket.id;
|
||||
|
||||
const mismatchedVaultResponse = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/btc-deposits',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {
|
||||
clientId: 'client-3',
|
||||
basketId,
|
||||
chain138VaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mismatchedVaultResponse.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('validates payment payloads on the versioned route', async () => {
|
||||
server = await createServer({ disableSwagger: true });
|
||||
await server.ready();
|
||||
|
||||
const token = createJwt({
|
||||
id: 'finance-user',
|
||||
roles: ['finance'],
|
||||
});
|
||||
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/payments',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,191 +1,236 @@
|
||||
/**
|
||||
* Finance Service
|
||||
* Handles payments, ledgers, rate models, and invoicing
|
||||
* Handles payments, ledgers, and BTC jewelry-box workflows.
|
||||
*/
|
||||
|
||||
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
|
||||
import { fileURLToPath } from 'url';
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUI from '@fastify/swagger-ui';
|
||||
import {
|
||||
AppError,
|
||||
errorHandler,
|
||||
createLogger,
|
||||
registerSecurityPlugins,
|
||||
addCorrelationId,
|
||||
addRequestLogging,
|
||||
getEnv,
|
||||
createBodySchema,
|
||||
authenticateJWT,
|
||||
requireRole,
|
||||
} from '@the-order/shared';
|
||||
import { CreateLedgerEntrySchema, CreatePaymentSchema } from '@the-order/schemas';
|
||||
import { healthCheck as dbHealthCheck, getPool, createLedgerEntry, createPayment, updatePaymentStatus } from '@the-order/database';
|
||||
import {
|
||||
type BasketMandate,
|
||||
type CreateBasketMandate,
|
||||
type CreateBtcDeposit,
|
||||
type CreateBridgeWithdrawal,
|
||||
} from '@the-order/schemas';
|
||||
import {
|
||||
healthCheck as dbHealthCheck,
|
||||
getPool,
|
||||
createLedgerEntry,
|
||||
createPayment,
|
||||
updatePaymentStatus,
|
||||
} from '@the-order/database';
|
||||
import { StripePaymentGateway } from '@the-order/payment-gateway';
|
||||
import {
|
||||
JewelryBoxStore,
|
||||
createDefaultBridgeWithdrawal,
|
||||
} from './jewelry-box-store';
|
||||
|
||||
const logger = createLogger('finance-service');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const server: any = Fastify({
|
||||
logger: logger as any,
|
||||
requestIdLogLabel: 'requestId',
|
||||
disableRequestLogging: false,
|
||||
});
|
||||
|
||||
// Initialize database pool
|
||||
const env = getEnv();
|
||||
if (env.DATABASE_URL) {
|
||||
getPool({ connectionString: env.DATABASE_URL });
|
||||
const PRIVILEGED_FINANCE_ROLES = new Set(['admin', 'accountant', 'finance']);
|
||||
|
||||
export type CreateServerOptions = {
|
||||
jewelryBoxStore?: JewelryBoxStore;
|
||||
disableSwagger?: boolean;
|
||||
};
|
||||
|
||||
function isPrivilegedFinanceUser(request: FastifyRequest): boolean {
|
||||
return (request.user?.roles ?? []).some((role) => PRIVILEGED_FINANCE_ROLES.has(role));
|
||||
}
|
||||
|
||||
// Initialize payment gateway
|
||||
let paymentGateway: StripePaymentGateway | null = null;
|
||||
try {
|
||||
if (env.PAYMENT_GATEWAY_API_KEY) {
|
||||
paymentGateway = new StripePaymentGateway();
|
||||
function assertClientAccess(request: FastifyRequest, clientId: string): void {
|
||||
if (!request.user) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, 'Payment gateway not configured');
|
||||
|
||||
if (request.user.id === clientId || isPrivilegedFinanceUser(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new AppError(403, 'FORBIDDEN', 'Cannot access another client\'s finance basket');
|
||||
}
|
||||
|
||||
// Initialize server
|
||||
async function initializeServer(): Promise<void> {
|
||||
// Register Swagger
|
||||
const swaggerUrl = env.SWAGGER_SERVER_URL || (env.NODE_ENV === 'development' ? 'http://localhost:4003' : undefined);
|
||||
function resolveClientScope(request: FastifyRequest, clientId?: string): string | undefined {
|
||||
if (clientId) {
|
||||
assertClientAccess(request, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
return isPrivilegedFinanceUser(request) ? undefined : request.user?.id;
|
||||
}
|
||||
|
||||
function getAccessibleBasket(
|
||||
request: FastifyRequest,
|
||||
store: JewelryBoxStore,
|
||||
basketId: string,
|
||||
): BasketMandate {
|
||||
const basket = store.getBasket(basketId);
|
||||
if (!basket) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Basket not found');
|
||||
}
|
||||
|
||||
assertClientAccess(request, basket.clientId);
|
||||
return basket;
|
||||
}
|
||||
|
||||
function createPaymentGateway(): StripePaymentGateway | null {
|
||||
try {
|
||||
return env.PAYMENT_GATEWAY_API_KEY ? new StripePaymentGateway() : null;
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, 'Payment gateway not configured');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeDbHealthCheck(): Promise<boolean> {
|
||||
try {
|
||||
return await dbHealthCheck();
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, 'Database health check failed');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerDocs(server: any, disableSwagger = false): Promise<void> {
|
||||
if (disableSwagger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const swaggerUrl =
|
||||
env.SWAGGER_SERVER_URL || (env.NODE_ENV === 'development' ? 'http://localhost:4003' : undefined);
|
||||
|
||||
if (!swaggerUrl) {
|
||||
logger.warn('SWAGGER_SERVER_URL not set, Swagger documentation will not be available');
|
||||
} else {
|
||||
await server.register(fastifySwagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Finance Service API',
|
||||
description: 'Payments, ledgers, rate models, and invoicing',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: swaggerUrl,
|
||||
description: env.NODE_ENV || 'Development server',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await server.register(fastifySwaggerUI, {
|
||||
routePrefix: '/docs',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await registerSecurityPlugins(server as any);
|
||||
addCorrelationId(server as any);
|
||||
addRequestLogging(server as any);
|
||||
server.setErrorHandler(errorHandler as any);
|
||||
await server.register(fastifySwagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Finance Service API',
|
||||
description: 'Payments, ledgers, and BTC jewelry-box workflows',
|
||||
version: '1.0.0',
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: swaggerUrl,
|
||||
description: env.NODE_ENV || 'Development server',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await server.register(fastifySwaggerUI, {
|
||||
routePrefix: '/docs',
|
||||
});
|
||||
}
|
||||
|
||||
// Health check
|
||||
server.get(
|
||||
'/health',
|
||||
{
|
||||
schema: {
|
||||
description: 'Health check endpoint',
|
||||
tags: ['health'],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
service: { type: 'string' },
|
||||
database: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const dbHealthy = await dbHealthCheck();
|
||||
return {
|
||||
status: dbHealthy ? 'ok' : 'degraded',
|
||||
service: 'finance',
|
||||
database: dbHealthy ? 'connected' : 'disconnected',
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Ledger operations
|
||||
server.post(
|
||||
'/ledger/entry',
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
preHandler: [authenticateJWT as any, requireRole('admin', 'accountant', 'finance') as any],
|
||||
schema: {
|
||||
...createBodySchema(CreateLedgerEntrySchema),
|
||||
description: 'Create a ledger entry',
|
||||
tags: ['ledger'],
|
||||
response: {
|
||||
201: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
entry: {
|
||||
type: 'object',
|
||||
function registerHealthRoute(server: any): void {
|
||||
server.get(
|
||||
'/health',
|
||||
{
|
||||
schema: {
|
||||
description: 'Health check endpoint',
|
||||
tags: ['health'],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
service: { type: 'string' },
|
||||
database: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = request.body as {
|
||||
accountId: string;
|
||||
type: 'debit' | 'credit';
|
||||
amount: number;
|
||||
currency: string;
|
||||
description?: string;
|
||||
reference?: string;
|
||||
};
|
||||
async () => {
|
||||
const dbHealthy = await safeDbHealthCheck();
|
||||
return {
|
||||
status: dbHealthy ? 'ok' : 'degraded',
|
||||
service: 'finance',
|
||||
database: dbHealthy ? 'connected' : 'disconnected',
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const entry = await createLedgerEntry({
|
||||
account_id: body.accountId,
|
||||
type: body.type,
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
description: body.description,
|
||||
reference: body.reference,
|
||||
});
|
||||
|
||||
return reply.status(201).send({ entry });
|
||||
}
|
||||
);
|
||||
|
||||
// Payment processing
|
||||
server.post(
|
||||
'/payments',
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
preHandler: [authenticateJWT as any],
|
||||
schema: {
|
||||
...createBodySchema(CreatePaymentSchema),
|
||||
description: 'Process a payment',
|
||||
tags: ['payments'],
|
||||
response: {
|
||||
201: {
|
||||
function registerLedgerRoute(server: any): void {
|
||||
server.post(
|
||||
'/ledger/entry',
|
||||
{
|
||||
preHandler: [authenticateJWT as never, requireRole('admin', 'accountant', 'finance') as never],
|
||||
schema: {
|
||||
description: 'Create a ledger entry',
|
||||
tags: ['ledger'],
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['accountId', 'type', 'amount', 'currency'],
|
||||
properties: {
|
||||
payment: {
|
||||
type: 'object',
|
||||
},
|
||||
accountId: { type: 'string' },
|
||||
type: { type: 'string', enum: ['debit', 'credit'] },
|
||||
amount: { type: 'number', exclusiveMinimum: 0 },
|
||||
currency: { type: 'string', minLength: 3, maxLength: 3 },
|
||||
description: { type: 'string' },
|
||||
reference: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = request.body as {
|
||||
amount: number;
|
||||
currency: string;
|
||||
paymentMethod: string;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
accountId: string;
|
||||
type: 'debit' | 'credit';
|
||||
amount: number;
|
||||
currency: string;
|
||||
description?: string;
|
||||
reference?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const body = request.body;
|
||||
const entry = await createLedgerEntry({
|
||||
account_id: body.accountId,
|
||||
type: body.type,
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
description: body.description,
|
||||
reference: body.reference,
|
||||
});
|
||||
|
||||
// Create payment record
|
||||
return reply.status(201).send({ entry });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerPaymentsRoute(server: any, paymentGateway: StripePaymentGateway | null): void {
|
||||
const handler = async (
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
paymentMethod: string;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const body = request.body;
|
||||
const payment = await createPayment({
|
||||
amount: body.amount,
|
||||
currency: body.currency,
|
||||
@@ -193,53 +238,324 @@ server.post(
|
||||
payment_method: body.paymentMethod,
|
||||
});
|
||||
|
||||
// Process payment through gateway if available
|
||||
if (paymentGateway) {
|
||||
try {
|
||||
const result = await paymentGateway.processPayment(
|
||||
body.amount,
|
||||
body.currency,
|
||||
body.paymentMethod,
|
||||
{
|
||||
payment_id: payment.id,
|
||||
...body.metadata,
|
||||
}
|
||||
);
|
||||
|
||||
// Update payment status
|
||||
const updatedPayment = await updatePaymentStatus(
|
||||
payment.id,
|
||||
result.status,
|
||||
result.transactionId,
|
||||
result.gatewayResponse
|
||||
);
|
||||
|
||||
return reply.status(201).send({ payment: updatedPayment });
|
||||
} catch (error) {
|
||||
logger.error({ err: error, paymentId: payment.id }, 'Payment processing failed');
|
||||
await updatePaymentStatus(payment.id, 'failed', undefined, { error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// No payment gateway configured - return pending status
|
||||
if (!paymentGateway) {
|
||||
return reply.status(201).send({ payment });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Start server
|
||||
const start = async () => {
|
||||
try {
|
||||
const result = await paymentGateway.processPayment(
|
||||
body.amount,
|
||||
body.currency,
|
||||
body.paymentMethod,
|
||||
{
|
||||
payment_id: payment.id,
|
||||
...body.metadata,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPayment = await updatePaymentStatus(
|
||||
payment.id,
|
||||
result.status,
|
||||
result.transactionId,
|
||||
result.gatewayResponse,
|
||||
);
|
||||
|
||||
return reply.status(201).send({ payment: updatedPayment });
|
||||
} catch (error) {
|
||||
logger.error({ err: error, paymentId: payment.id }, 'Payment processing failed');
|
||||
await updatePaymentStatus(payment.id, 'failed', undefined, { error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const options = {
|
||||
preHandler: [authenticateJWT as never],
|
||||
schema: {
|
||||
description: 'Process a payment',
|
||||
tags: ['payments'],
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['amount', 'currency', 'paymentMethod'],
|
||||
properties: {
|
||||
amount: { type: 'number', exclusiveMinimum: 0 },
|
||||
currency: { type: 'string', minLength: 3, maxLength: 3 },
|
||||
paymentMethod: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
server.post('/payments', options, handler);
|
||||
server.post('/api/v1/payments', options, handler);
|
||||
}
|
||||
|
||||
function registerJewelryBoxRoutes(server: any, store: JewelryBoxStore): void {
|
||||
server.post(
|
||||
'/api/v1/baskets',
|
||||
{
|
||||
preHandler: [authenticateJWT as never],
|
||||
schema: {
|
||||
description: 'Create a Chain 138 basket mandate for BTC settlement flows',
|
||||
tags: ['jewelry-box'],
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['clientId', 'mandateName', 'chain138VaultAddress', 'allocations'],
|
||||
properties: {
|
||||
clientId: { type: 'string', minLength: 1 },
|
||||
mandateName: { type: 'string', minLength: 1 },
|
||||
chain138VaultAddress: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
|
||||
baseAssetSymbol: { type: 'string', minLength: 2, maxLength: 16 },
|
||||
allocations: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['symbol', 'targetWeightBps'],
|
||||
properties: {
|
||||
symbol: { type: 'string', minLength: 2, maxLength: 16 },
|
||||
targetWeightBps: { type: 'integer', minimum: 1, maximum: 10000 },
|
||||
routeHint: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{ Body: CreateBasketMandate }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
assertClientAccess(request, request.body.clientId);
|
||||
const basket = store.createBasket(request.body);
|
||||
return reply.status(201).send({ basket });
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
'/api/v1/btc-deposits',
|
||||
{
|
||||
preHandler: [authenticateJWT as never],
|
||||
schema: {
|
||||
description: 'Create a BTC deposit instruction and associate it with a jewelry-box basket',
|
||||
tags: ['jewelry-box'],
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['clientId', 'chain138VaultAddress'],
|
||||
properties: {
|
||||
clientId: { type: 'string', minLength: 1 },
|
||||
basketId: { type: 'string' },
|
||||
mandateName: { type: 'string', minLength: 1 },
|
||||
chain138VaultAddress: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
|
||||
expectedAmountSats: { type: 'integer', minimum: 1 },
|
||||
clientReference: { type: 'string', minLength: 1 },
|
||||
allocations: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['symbol', 'targetWeightBps'],
|
||||
properties: {
|
||||
symbol: { type: 'string', minLength: 2, maxLength: 16 },
|
||||
targetWeightBps: { type: 'integer', minimum: 1, maximum: 10000 },
|
||||
routeHint: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{ Body: CreateBtcDeposit }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
assertClientAccess(request, request.body.clientId);
|
||||
|
||||
if (request.body.basketId) {
|
||||
const basket = getAccessibleBasket(request, store, request.body.basketId);
|
||||
if (basket.clientId !== request.body.clientId) {
|
||||
throw new AppError(400, 'CLIENT_MISMATCH', 'Deposit client does not match basket client');
|
||||
}
|
||||
if (basket.chain138VaultAddress !== request.body.chain138VaultAddress) {
|
||||
throw new AppError(
|
||||
400,
|
||||
'VAULT_ADDRESS_MISMATCH',
|
||||
'Deposit vault address does not match the selected basket',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = store.createDeposit(request.body);
|
||||
return reply.status(201).send(result);
|
||||
},
|
||||
);
|
||||
|
||||
server.get(
|
||||
'/api/v1/btc-deposits/:id',
|
||||
{
|
||||
preHandler: [authenticateJWT as never],
|
||||
schema: {
|
||||
description: 'Get BTC deposit status',
|
||||
tags: ['jewelry-box'],
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
) => {
|
||||
const deposit = store.getDeposit(request.params.id);
|
||||
if (!deposit) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Deposit not found');
|
||||
}
|
||||
|
||||
assertClientAccess(request, deposit.clientId);
|
||||
return { deposit };
|
||||
},
|
||||
);
|
||||
|
||||
server.get(
|
||||
'/api/v1/holdings',
|
||||
{
|
||||
preHandler: [authenticateJWT as never],
|
||||
schema: {
|
||||
description: 'List basket holdings derived from underlying allocations',
|
||||
tags: ['jewelry-box'],
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{ Querystring: { clientId?: string; basketId?: string } }>,
|
||||
) => {
|
||||
if (request.query.basketId) {
|
||||
const basket = getAccessibleBasket(request, store, request.query.basketId);
|
||||
if (request.query.clientId && basket.clientId !== request.query.clientId) {
|
||||
throw new AppError(400, 'CLIENT_MISMATCH', 'Requested client does not own the selected basket');
|
||||
}
|
||||
}
|
||||
|
||||
const holdings = store.listHoldings({
|
||||
...request.query,
|
||||
clientId: resolveClientScope(request, request.query.clientId),
|
||||
});
|
||||
return { holdings };
|
||||
},
|
||||
);
|
||||
|
||||
server.get(
|
||||
'/api/v1/rebalances',
|
||||
{
|
||||
preHandler: [authenticateJWT as never],
|
||||
schema: {
|
||||
description: 'List planned or queued rebalances for jewelry-box baskets',
|
||||
tags: ['jewelry-box'],
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{ Querystring: { clientId?: string; basketId?: string } }>,
|
||||
) => {
|
||||
if (request.query.basketId) {
|
||||
const basket = getAccessibleBasket(request, store, request.query.basketId);
|
||||
if (request.query.clientId && basket.clientId !== request.query.clientId) {
|
||||
throw new AppError(400, 'CLIENT_MISMATCH', 'Requested client does not own the selected basket');
|
||||
}
|
||||
}
|
||||
|
||||
const rebalances = store.listRebalances({
|
||||
...request.query,
|
||||
clientId: resolveClientScope(request, request.query.clientId),
|
||||
});
|
||||
return { rebalances };
|
||||
},
|
||||
);
|
||||
|
||||
server.post(
|
||||
'/api/v1/withdrawals/bridge',
|
||||
{
|
||||
preHandler: [authenticateJWT as never],
|
||||
schema: {
|
||||
description: 'Request a public-chain bridge withdrawal from a jewelry-box basket',
|
||||
tags: ['jewelry-box'],
|
||||
body: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['clientId', 'basketId', 'destinationChainId', 'destinationAddress', 'amount'],
|
||||
properties: {
|
||||
clientId: { type: 'string', minLength: 1 },
|
||||
basketId: { type: 'string', minLength: 1 },
|
||||
sourceSymbol: { type: 'string', minLength: 2, maxLength: 16 },
|
||||
destinationSymbol: { type: 'string', minLength: 2, maxLength: 16 },
|
||||
destinationChainId: { type: 'integer', minimum: 1 },
|
||||
destinationAddress: { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' },
|
||||
amount: { type: 'string', minLength: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{ Body: CreateBridgeWithdrawal }>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const basket = getAccessibleBasket(request, store, request.body.basketId);
|
||||
if (basket.clientId !== request.body.clientId) {
|
||||
throw new AppError(400, 'CLIENT_MISMATCH', 'Withdrawal client does not match basket client');
|
||||
}
|
||||
|
||||
const withdrawal = store.createBridgeWithdrawal(
|
||||
createDefaultBridgeWithdrawal(request.body as CreateBridgeWithdrawal),
|
||||
);
|
||||
return reply.status(201).send({ withdrawal });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createServer(options: CreateServerOptions = {}): Promise<FastifyInstance> {
|
||||
if (env.DATABASE_URL) {
|
||||
getPool({ connectionString: env.DATABASE_URL });
|
||||
}
|
||||
|
||||
const server: any = Fastify({
|
||||
logger: logger as any,
|
||||
requestIdLogLabel: 'requestId',
|
||||
disableRequestLogging: false,
|
||||
});
|
||||
|
||||
await registerDocs(server, options.disableSwagger);
|
||||
await registerSecurityPlugins(server as any);
|
||||
addCorrelationId(server);
|
||||
addRequestLogging(server);
|
||||
server.setErrorHandler(errorHandler as any);
|
||||
|
||||
registerHealthRoute(server);
|
||||
registerLedgerRoute(server);
|
||||
registerPaymentsRoute(server, createPaymentGateway());
|
||||
registerJewelryBoxRoutes(server, options.jewelryBoxStore || new JewelryBoxStore());
|
||||
|
||||
return server as FastifyInstance;
|
||||
}
|
||||
|
||||
export async function start(): Promise<void> {
|
||||
try {
|
||||
await initializeServer();
|
||||
const env = getEnv();
|
||||
const server = await createServer();
|
||||
const port = env.PORT || 4003;
|
||||
await server.listen({ port, host: '0.0.0.0' });
|
||||
logger.info({ port }, 'Finance service listening');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to start server');
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Failed to start server');
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
start();
|
||||
const isDirectExecution = process.argv[1] === fileURLToPath(import.meta.url);
|
||||
|
||||
if (isDirectExecution) {
|
||||
void start();
|
||||
}
|
||||
|
||||
296
services/finance/src/jewelry-box-store.ts
Normal file
296
services/finance/src/jewelry-box-store.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
BasketAllocation,
|
||||
BasketMandate,
|
||||
BtcDeposit,
|
||||
BridgeWithdrawal,
|
||||
CreateBasketMandate,
|
||||
CreateBridgeWithdrawal,
|
||||
CreateBtcDeposit,
|
||||
Holding,
|
||||
Rebalance,
|
||||
} from '@the-order/schemas';
|
||||
|
||||
const BTC_CONFIRMATIONS_REQUIRED = 6;
|
||||
const DEFAULT_ALLOCATION: BasketAllocation = { symbol: 'cBTC', targetWeightBps: 10_000 };
|
||||
const BTC_BRIDGE_SYMBOL = 'cWBTC';
|
||||
const BECH32_ALPHABET = '023456789acdefghjklmnpqrstuvwxyz';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function sumWeights(allocations: BasketAllocation[]): number {
|
||||
return allocations.reduce((total, allocation) => total + allocation.targetWeightBps, 0);
|
||||
}
|
||||
|
||||
function toPseudoBitcoinAddress(seed: string): string {
|
||||
const normalized = seed.replace(/[^a-f0-9]/gi, '').toLowerCase() || 'cb7c138';
|
||||
let out = 'bc1q';
|
||||
for (let index = 0; index < 32; index += 1) {
|
||||
const source = normalized[index % normalized.length] ?? '0';
|
||||
const value = parseInt(source, 16);
|
||||
out += Number.isNaN(value) ? 'q' : BECH32_ALPHABET[value];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export class JewelryBoxStore {
|
||||
private readonly baskets = new Map<string, BasketMandate>();
|
||||
private readonly deposits = new Map<string, BtcDeposit>();
|
||||
private readonly rebalances = new Map<string, Rebalance>();
|
||||
private readonly withdrawals = new Map<string, BridgeWithdrawal>();
|
||||
|
||||
createBasket(input: CreateBasketMandate): BasketMandate {
|
||||
const allocations = input.allocations.length > 0 ? input.allocations : [DEFAULT_ALLOCATION];
|
||||
if (sumWeights(allocations) !== 10_000) {
|
||||
throw new Error('Basket allocations must sum to exactly 10,000 bps');
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const basket: BasketMandate = {
|
||||
id: randomUUID(),
|
||||
clientId: input.clientId,
|
||||
mandateName: input.mandateName,
|
||||
chain138VaultAddress: input.chain138VaultAddress,
|
||||
baseAssetSymbol: input.baseAssetSymbol || 'cBTC',
|
||||
status: 'active',
|
||||
allocations: clone(allocations),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
this.baskets.set(basket.id, basket);
|
||||
this.seedRebalancePlan(basket);
|
||||
return clone(basket);
|
||||
}
|
||||
|
||||
getBasket(id: string): BasketMandate | null {
|
||||
const basket = this.baskets.get(id);
|
||||
return basket ? clone(basket) : null;
|
||||
}
|
||||
|
||||
createDeposit(input: CreateBtcDeposit): { deposit: BtcDeposit; basket: BasketMandate; createdBasket: boolean } {
|
||||
let basket = input.basketId ? this.baskets.get(input.basketId) ?? null : null;
|
||||
let createdBasket = false;
|
||||
|
||||
if (input.basketId && !basket) {
|
||||
throw new Error(`Unknown basket: ${input.basketId}`);
|
||||
}
|
||||
|
||||
if (!basket) {
|
||||
basket = this.createBasket({
|
||||
clientId: input.clientId,
|
||||
mandateName: input.mandateName || `BTC jewelry box ${input.clientId}`,
|
||||
chain138VaultAddress: input.chain138VaultAddress,
|
||||
allocations: input.allocations && input.allocations.length > 0 ? input.allocations : [DEFAULT_ALLOCATION],
|
||||
baseAssetSymbol: 'cBTC',
|
||||
});
|
||||
createdBasket = true;
|
||||
} else {
|
||||
if (basket.clientId !== input.clientId) {
|
||||
throw new Error('Basket client does not match deposit client');
|
||||
}
|
||||
if (basket.chain138VaultAddress !== input.chain138VaultAddress) {
|
||||
throw new Error('Basket vault address does not match deposit vault address');
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const depositId = randomUUID();
|
||||
const deposit: BtcDeposit = {
|
||||
id: depositId,
|
||||
clientId: input.clientId,
|
||||
basketId: basket.id,
|
||||
chain138VaultAddress: basket.chain138VaultAddress,
|
||||
depositAddress: toPseudoBitcoinAddress(depositId),
|
||||
expectedAmountSats: input.expectedAmountSats,
|
||||
confirmationsRequired: BTC_CONFIRMATIONS_REQUIRED,
|
||||
currentConfirmations: 0,
|
||||
status: 'instruction_created',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
this.deposits.set(deposit.id, deposit);
|
||||
return { deposit: clone(deposit), basket: clone(basket), createdBasket };
|
||||
}
|
||||
|
||||
getDeposit(id: string): BtcDeposit | null {
|
||||
const deposit = this.deposits.get(id);
|
||||
return deposit ? clone(deposit) : null;
|
||||
}
|
||||
|
||||
listHoldings(filters: { clientId?: string; basketId?: string }): Holding[] {
|
||||
const baskets = Array.from(this.baskets.values()).filter((basket) => {
|
||||
if (filters.clientId && basket.clientId !== filters.clientId) {
|
||||
return false;
|
||||
}
|
||||
if (filters.basketId && basket.id !== filters.basketId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return baskets.flatMap((basket) => {
|
||||
const fundedSats = Array.from(this.deposits.values())
|
||||
.filter((deposit) => deposit.basketId === basket.id && deposit.status === 'minted')
|
||||
.reduce((total, deposit) => total + (deposit.expectedAmountSats ?? 0), 0);
|
||||
|
||||
return basket.allocations.map((allocation: BasketAllocation) => ({
|
||||
clientId: basket.clientId,
|
||||
basketId: basket.id,
|
||||
symbol: allocation.symbol,
|
||||
allocationWeightBps: allocation.targetWeightBps,
|
||||
bookValueSats: Math.floor((fundedSats * allocation.targetWeightBps) / 10_000),
|
||||
status: fundedSats > 0 ? 'funded' : 'pending_funding',
|
||||
updatedAt: basket.updatedAt,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
listRebalances(filters: { clientId?: string; basketId?: string }): Rebalance[] {
|
||||
return Array.from(this.rebalances.values())
|
||||
.filter((rebalance) => {
|
||||
if (filters.clientId && rebalance.clientId !== filters.clientId) {
|
||||
return false;
|
||||
}
|
||||
if (filters.basketId && rebalance.basketId !== filters.basketId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((rebalance) => clone(rebalance));
|
||||
}
|
||||
|
||||
createBridgeWithdrawal(input: CreateBridgeWithdrawal): BridgeWithdrawal {
|
||||
if (!this.baskets.has(input.basketId)) {
|
||||
throw new Error(`Unknown basket: ${input.basketId}`);
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const withdrawal: BridgeWithdrawal = {
|
||||
id: randomUUID(),
|
||||
clientId: input.clientId,
|
||||
basketId: input.basketId,
|
||||
sourceSymbol: input.sourceSymbol || 'cBTC',
|
||||
destinationSymbol: input.destinationSymbol || BTC_BRIDGE_SYMBOL,
|
||||
destinationChainId: input.destinationChainId,
|
||||
destinationAddress: input.destinationAddress,
|
||||
amount: input.amount,
|
||||
status: 'pending',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
this.withdrawals.set(withdrawal.id, withdrawal);
|
||||
return clone(withdrawal);
|
||||
}
|
||||
|
||||
markDepositObserved(
|
||||
depositId: string,
|
||||
observedTxId: string,
|
||||
confirmations: number,
|
||||
expectedAmountSats?: number,
|
||||
): BtcDeposit {
|
||||
const deposit = this.deposits.get(depositId);
|
||||
if (!deposit) {
|
||||
throw new Error(`Unknown BTC deposit: ${depositId}`);
|
||||
}
|
||||
|
||||
const nextConfirmations = Math.max(0, confirmations);
|
||||
deposit.observedTxId = observedTxId;
|
||||
deposit.currentConfirmations = nextConfirmations;
|
||||
if (expectedAmountSats !== undefined) {
|
||||
deposit.expectedAmountSats = expectedAmountSats;
|
||||
}
|
||||
deposit.status =
|
||||
nextConfirmations >= deposit.confirmationsRequired ? 'confirmed' : 'pending_confirmations';
|
||||
deposit.updatedAt = nowIso();
|
||||
|
||||
return clone(deposit);
|
||||
}
|
||||
|
||||
markDepositMinted(depositId: string): BtcDeposit {
|
||||
const deposit = this.deposits.get(depositId);
|
||||
if (!deposit) {
|
||||
throw new Error(`Unknown BTC deposit: ${depositId}`);
|
||||
}
|
||||
|
||||
deposit.currentConfirmations = Math.max(deposit.currentConfirmations, deposit.confirmationsRequired);
|
||||
deposit.status = 'minted';
|
||||
deposit.updatedAt = nowIso();
|
||||
|
||||
const basket = this.baskets.get(deposit.basketId);
|
||||
if (basket) {
|
||||
basket.updatedAt = deposit.updatedAt;
|
||||
if (basket.allocations.some((allocation: BasketAllocation) => allocation.symbol !== 'cBTC')) {
|
||||
this.promoteRebalancePlan(basket);
|
||||
}
|
||||
}
|
||||
|
||||
return clone(deposit);
|
||||
}
|
||||
|
||||
freezeDeposit(depositId: string, reason: string): BtcDeposit {
|
||||
const deposit = this.deposits.get(depositId);
|
||||
if (!deposit) {
|
||||
throw new Error(`Unknown BTC deposit: ${depositId}`);
|
||||
}
|
||||
deposit.status = 'frozen';
|
||||
deposit.freezeReason = reason;
|
||||
deposit.updatedAt = nowIso();
|
||||
return clone(deposit);
|
||||
}
|
||||
|
||||
private seedRebalancePlan(basket: BasketMandate): void {
|
||||
const targetSymbols = basket.allocations
|
||||
.map((allocation: BasketAllocation) => allocation.symbol)
|
||||
.filter((symbol: string) => symbol !== 'cBTC');
|
||||
|
||||
if (targetSymbols.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const rebalance: Rebalance = {
|
||||
id: randomUUID(),
|
||||
clientId: basket.clientId,
|
||||
basketId: basket.id,
|
||||
sourceSymbol: 'cBTC',
|
||||
targetSymbols,
|
||||
reason: 'Awaiting funded BTC deposit before Chain 138 rebalance',
|
||||
status: 'planned',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
this.rebalances.set(rebalance.id, rebalance);
|
||||
}
|
||||
|
||||
private promoteRebalancePlan(basket: BasketMandate): void {
|
||||
for (const rebalance of this.rebalances.values()) {
|
||||
if (rebalance.basketId === basket.id && rebalance.status === 'planned') {
|
||||
rebalance.status = 'queued';
|
||||
rebalance.updatedAt = nowIso();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultBridgeWithdrawal(
|
||||
input: Omit<CreateBridgeWithdrawal, 'destinationSymbol' | 'sourceSymbol'> & {
|
||||
sourceSymbol?: string;
|
||||
destinationSymbol?: string;
|
||||
},
|
||||
): CreateBridgeWithdrawal {
|
||||
return {
|
||||
...input,
|
||||
sourceSymbol: input.sourceSymbol || 'cBTC',
|
||||
destinationSymbol: input.destinationSymbol || BTC_BRIDGE_SYMBOL,
|
||||
};
|
||||
}
|
||||
@@ -1,45 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createServer } from '../src/index';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { JewelryBoxStore } from '../src/jewelry-box-store';
|
||||
|
||||
describe('Finance Service', () => {
|
||||
let server: FastifyInstance;
|
||||
describe('JewelryBoxStore', () => {
|
||||
it('creates queued rebalance work only for non-cBTC allocations', () => {
|
||||
const store = new JewelryBoxStore();
|
||||
const basket = store.createBasket({
|
||||
clientId: 'client-1',
|
||||
mandateName: 'Multi-asset jewelry box',
|
||||
chain138VaultAddress: '0x4444444444444444444444444444444444444444',
|
||||
allocations: [
|
||||
{ symbol: 'cBTC', targetWeightBps: 2500 },
|
||||
{ symbol: 'cUSDC', targetWeightBps: 5000 },
|
||||
{ symbol: 'cXAUC', targetWeightBps: 2500 },
|
||||
],
|
||||
baseAssetSymbol: 'cBTC',
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await createServer();
|
||||
await server.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe('Health Check', () => {
|
||||
it('should return 200 on health check', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/health',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
status: 'ok',
|
||||
});
|
||||
const rebalances = store.listRebalances({ basketId: basket.id });
|
||||
expect(rebalances).toHaveLength(1);
|
||||
expect(rebalances[0]).toMatchObject({
|
||||
sourceSymbol: 'cBTC',
|
||||
targetSymbols: ['cUSDC', 'cXAUC'],
|
||||
status: 'planned',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payment Processing', () => {
|
||||
it('should validate payment request schema', async () => {
|
||||
const response = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/payments',
|
||||
payload: {
|
||||
// Invalid payload to test validation
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
it('promotes rebalance plans once a deposit is marked minted', () => {
|
||||
const store = new JewelryBoxStore();
|
||||
const { basket, deposit } = store.createDeposit({
|
||||
clientId: 'client-2',
|
||||
chain138VaultAddress: '0x5555555555555555555555555555555555555555',
|
||||
allocations: [
|
||||
{ symbol: 'cBTC', targetWeightBps: 3000 },
|
||||
{ symbol: 'cUSDT', targetWeightBps: 7000 },
|
||||
],
|
||||
expectedAmountSats: 300000000,
|
||||
});
|
||||
|
||||
store.markDepositObserved(deposit.id, 'btc-tx-1', 6, 300000000);
|
||||
store.markDepositMinted(deposit.id);
|
||||
|
||||
const updatedDeposit = store.getDeposit(deposit.id);
|
||||
expect(updatedDeposit?.status).toBe('minted');
|
||||
|
||||
const holdings = store.listHoldings({ basketId: basket.id });
|
||||
expect(holdings[1]).toMatchObject({
|
||||
symbol: 'cUSDT',
|
||||
bookValueSats: 210000000,
|
||||
status: 'funded',
|
||||
});
|
||||
|
||||
const rebalances = store.listRebalances({ basketId: basket.id });
|
||||
expect(rebalances[0]?.status).toBe('queued');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
18
services/finance/vitest.config.mjs
Normal file
18
services/finance/vitest.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from 'path';
|
||||
|
||||
const root = '/home/intlc/projects/proxmox/the-order';
|
||||
|
||||
export default {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@the-order/auth': path.join(root, 'packages/auth/src/index.ts'),
|
||||
'@the-order/shared': path.join(root, 'packages/shared/src/index.ts'),
|
||||
'@the-order/schemas': path.join(root, 'packages/schemas/src/index.ts'),
|
||||
'@the-order/database': path.join(root, 'packages/database/src/index.ts'),
|
||||
'@the-order/payment-gateway': path.join(root, 'packages/payment-gateway/src/index.ts'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user