From e4f29567780e45a17418077bd941c92fd4570fab Mon Sep 17 00:00:00 2001 From: Day-veed Date: Wed, 25 Mar 2026 00:33:35 +0100 Subject: [PATCH] feat: added processAnchorRefund function --- packages/anchor-service/jest.config.cjs | 10 ++ packages/anchor-service/package.json | 7 +- packages/anchor-service/src/index.spec.ts | 149 +++++++++++++++++++++ packages/anchor-service/src/index.ts | 137 +++++++++++++++++++ packages/anchor-service/tsconfig.json | 5 + packages/anchor-service/tsconfig.test.json | 13 ++ 6 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 packages/anchor-service/jest.config.cjs create mode 100644 packages/anchor-service/src/index.spec.ts create mode 100644 packages/anchor-service/tsconfig.json create mode 100644 packages/anchor-service/tsconfig.test.json diff --git a/packages/anchor-service/jest.config.cjs b/packages/anchor-service/jest.config.cjs new file mode 100644 index 0000000..13e22fb --- /dev/null +++ b/packages/anchor-service/jest.config.cjs @@ -0,0 +1,10 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.spec.ts'], + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.test.json', + }, + }, +}; diff --git a/packages/anchor-service/package.json b/packages/anchor-service/package.json index 6855f1c..e231856 100644 --- a/packages/anchor-service/package.json +++ b/packages/anchor-service/package.json @@ -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" } diff --git a/packages/anchor-service/src/index.spec.ts b/packages/anchor-service/src/index.spec.ts new file mode 100644 index 0000000..3ee9582 --- /dev/null +++ b/packages/anchor-service/src/index.spec.ts @@ -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; +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, + }); + }); +}); diff --git a/packages/anchor-service/src/index.ts b/packages/anchor-service/src/index.ts index e69de29..a8bc28f 100644 --- a/packages/anchor-service/src/index.ts +++ b/packages/anchor-service/src/index.ts @@ -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; + update(transaction: AnchorTransaction): Promise; +} + +export interface RefundExecutionResult { + refundedAmount: number; + externalRefundId?: string; +} + +export interface AnchorRefundExecutor { + executeRefund(transaction: AnchorTransaction, amount: number): Promise; +} + +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 { + 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 { + 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, + }; +} \ No newline at end of file diff --git a/packages/anchor-service/tsconfig.json b/packages/anchor-service/tsconfig.json new file mode 100644 index 0000000..3f64b35 --- /dev/null +++ b/packages/anchor-service/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/anchor-service/tsconfig.test.json b/packages/anchor-service/tsconfig.test.json new file mode 100644 index 0000000..8f123c5 --- /dev/null +++ b/packages/anchor-service/tsconfig.test.json @@ -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"] +}