Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/anchor-service/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.spec.ts'],
globals: {
'ts-jest': {
tsconfig: 'tsconfig.test.json',
},
},
};
7 changes: 6 additions & 1 deletion packages/anchor-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
"lint": "eslint \"src/**/*.ts\""
"lint": "eslint \"src/**/*.ts\"",
"test": "jest -c jest.config.cjs"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"jest": "^30.0.0",
"ts-jest": "^29.2.5",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
}
Expand Down
149 changes: 149 additions & 0 deletions packages/anchor-service/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
processAnchorRefundWithDeps,
type AnchorRefundProcessorDeps,
type AnchorTransaction,
type AnchorTransactionRepository,
type AnchorRefundExecutor,
} from './index';

declare const describe: (name: string, fn: () => void) => void;
declare const test: (name: string, fn: () => void | Promise<void>) => void;
declare const expect: (actual: any) => any;

describe('processAnchorRefundWithDeps', () => {
const makeRepo = (initial: AnchorTransaction): AnchorTransactionRepository & { getState(): AnchorTransaction } => {
let state: AnchorTransaction = { ...initial };

return {
async getById(id: string) {
return id === state.id ? { ...state } : null;
},
async update(tx: AnchorTransaction) {
state = { ...tx };
},
getState() {
return { ...state };
},
};
};

test('processes full refund and updates status to refunded', async () => {
const repo = makeRepo({
id: 'tx1',
kind: 'deposit',
status: 'failed',
amount: 100,
refundedAmount: 0,
});

const refundExecutor: AnchorRefundExecutor = {
async executeRefund(_tx, amount) {
return { refundedAmount: amount, externalRefundId: 'r1' };
},
};

const deps: AnchorRefundProcessorDeps = { repository: repo, refundExecutor };

const result = await processAnchorRefundWithDeps('tx1', deps);

expect(result).toEqual({
transactionId: 'tx1',
attemptedAmount: 100,
refundedAmount: 100,
isPartial: false,
status: 'refunded',
externalRefundId: 'r1',
});

expect(repo.getState()).toMatchObject({
status: 'refunded',
refundedAmount: 100,
});
});

test('processes partial refund and updates status to partially_refunded', async () => {
const repo = makeRepo({
id: 'tx2',
kind: 'withdrawal',
status: 'failed',
amount: 100,
refundedAmount: 0,
});

const refundExecutor: AnchorRefundExecutor = {
async executeRefund(_tx, _amount) {
return { refundedAmount: 40, externalRefundId: 'r2' };
},
};

const deps: AnchorRefundProcessorDeps = { repository: repo, refundExecutor };

const result = await processAnchorRefundWithDeps('tx2', deps);

expect(result.transactionId).toBe('tx2');
expect(result.attemptedAmount).toBe(100);
expect(result.refundedAmount).toBe(40);
expect(result.isPartial).toBe(true);
expect(result.status).toBe('partially_refunded');

expect(repo.getState()).toMatchObject({
status: 'partially_refunded',
refundedAmount: 40,
});
});

test('skips refund when transaction is not failed', async () => {
const repo = makeRepo({
id: 'tx3',
kind: 'deposit',
status: 'completed',
amount: 100,
refundedAmount: 0,
});

const refundExecutor: AnchorRefundExecutor = {
async executeRefund() {
throw new Error('should not be called');
},
};

const deps: AnchorRefundProcessorDeps = { repository: repo, refundExecutor };

const result = await processAnchorRefundWithDeps('tx3', deps);

expect(result.attemptedAmount).toBe(0);
expect(result.refundedAmount).toBe(0);
expect(result.status).toBe('completed');
expect(result.message).toContain("expected 'failed'");

expect(repo.getState()).toMatchObject({
status: 'completed',
refundedAmount: 0,
});
});

test('marks transaction refund_failed if refund executor throws', async () => {
const repo = makeRepo({
id: 'tx4',
kind: 'deposit',
status: 'failed',
amount: 100,
refundedAmount: 0,
});

const refundExecutor: AnchorRefundExecutor = {
async executeRefund() {
throw new Error('network down');
},
};

const deps: AnchorRefundProcessorDeps = { repository: repo, refundExecutor };

await expect(processAnchorRefundWithDeps('tx4', deps)).rejects.toThrow('network down');

expect(repo.getState()).toMatchObject({
status: 'refund_failed',
refundedAmount: 0,
});
});
});
137 changes: 137 additions & 0 deletions packages/anchor-service/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
export type AnchorTransferKind = 'deposit' | 'withdrawal';

export type AnchorTransactionStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'refunded'
| 'partially_refunded'
| 'refund_failed';

export interface AnchorTransaction {
id: string;
kind: AnchorTransferKind;
status: AnchorTransactionStatus;
amount: number;
refundedAmount: number;
}

export interface RefundResult {
transactionId: string;
attemptedAmount: number;
refundedAmount: number;
isPartial: boolean;
status: AnchorTransactionStatus;
externalRefundId?: string;
message?: string;
}

export interface AnchorTransactionRepository {
getById(transactionId: string): Promise<AnchorTransaction | null>;
update(transaction: AnchorTransaction): Promise<void>;
}

export interface RefundExecutionResult {
refundedAmount: number;
externalRefundId?: string;
}

export interface AnchorRefundExecutor {
executeRefund(transaction: AnchorTransaction, amount: number): Promise<RefundExecutionResult>;
}

export interface AnchorRefundProcessorDeps {
repository: AnchorTransactionRepository;
refundExecutor: AnchorRefundExecutor;
}

let configuredDeps: AnchorRefundProcessorDeps | null = null;

export function configureAnchorRefundProcessor(deps: AnchorRefundProcessorDeps): void {
configuredDeps = deps;
}

export function resetAnchorRefundProcessorConfiguration(): void {
configuredDeps = null;
}

export async function processAnchorRefund(transactionId: string): Promise<RefundResult> {
if (!configuredDeps) {
throw new Error(
'Anchor refund processor not configured. Call configureAnchorRefundProcessor({ repository, refundExecutor }) first.',
);
}

return processAnchorRefundWithDeps(transactionId, configuredDeps);
}

export async function processAnchorRefundWithDeps(
transactionId: string,
deps: AnchorRefundProcessorDeps,
): Promise<RefundResult> {
const transaction = await deps.repository.getById(transactionId);
if (!transaction) {
throw new Error(`Transaction not found: ${transactionId}`);
}

if (transaction.kind !== 'deposit' && transaction.kind !== 'withdrawal') {
return {
transactionId,
attemptedAmount: 0,
refundedAmount: 0,
isPartial: false,
status: transaction.status,
message: `Unsupported transaction kind for refund: ${transaction.kind}`,
};
}

const refundableAmount = Math.max(0, transaction.amount - transaction.refundedAmount);

if (refundableAmount === 0) {
return {
transactionId,
attemptedAmount: 0,
refundedAmount: 0,
isPartial: false,
status: transaction.status,
message: 'Nothing to refund.',
};
}

if (transaction.status !== 'failed') {
return {
transactionId,
attemptedAmount: 0,
refundedAmount: 0,
isPartial: false,
status: transaction.status,
message: `Refund skipped. Transaction status is '${transaction.status}', expected 'failed'.`,
};
}

let executionResult: RefundExecutionResult;
try {
executionResult = await deps.refundExecutor.executeRefund(transaction, refundableAmount);
} catch (e) {
transaction.status = 'refund_failed';
await deps.repository.update(transaction);
throw e;
}

const refundedAmount = Math.max(0, Math.min(refundableAmount, executionResult.refundedAmount));
transaction.refundedAmount = Math.min(transaction.amount, transaction.refundedAmount + refundedAmount);

const isPartial = refundedAmount < refundableAmount;
transaction.status = transaction.refundedAmount >= transaction.amount ? 'refunded' : 'partially_refunded';
await deps.repository.update(transaction);

return {
transactionId,
attemptedAmount: refundableAmount,
refundedAmount,
isPartial,
status: transaction.status,
externalRefundId: executionResult.externalRefundId,
};
}
5 changes: 5 additions & 0 deletions packages/anchor-service/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
13 changes: 13 additions & 0 deletions packages/anchor-service/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"],
"lib": ["ESNext", "DOM"],
"module": "CommonJS",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.spec.ts"]
}
Loading