@@ -4,12 +4,16 @@ import { BadRequestException } from '@nestjs/common';
44import { EscrowService } from './escrow.service' ;
55import { SorobanClientService } from './soroban-client.service' ;
66import { Escrow , Payment } from '../common/entities' ;
7- import { AssetType , EscrowStatus } from '../common/enums' ;
7+ import { AssetType , EscrowStatus , PaymentStatus } from '../common/enums' ;
88
99describe ( 'EscrowService' , ( ) => {
1010 let service : EscrowService ;
1111 let escrowRepo : { create : jest . Mock ; save : jest . Mock ; findOne : jest . Mock } ;
12- let paymentRepo : { create : jest . Mock ; save : jest . Mock } ;
12+ let paymentRepo : {
13+ create : jest . Mock ;
14+ save : jest . Mock ;
15+ find : jest . Mock ;
16+ } ;
1317 let soroban : { invoke : jest . Mock } ;
1418
1519 beforeEach ( async ( ) => {
@@ -24,6 +28,7 @@ describe('EscrowService', () => {
2428 ...data ,
2529 } ) ) ,
2630 save : jest . fn ( ( data : Partial < Payment > ) => Promise . resolve ( data ) ) ,
31+ find : jest . fn ( ) . mockResolvedValue ( [ ] ) ,
2732 } ;
2833 soroban = {
2934 invoke : jest . fn ( ) . mockResolvedValue ( {
@@ -218,6 +223,137 @@ describe('EscrowService', () => {
218223 } ) ;
219224 } ) ;
220225
226+ describe ( 'releasePartial' , ( ) => {
227+ const lockedEscrow = ( ) => ( {
228+ id : 'escrow-partial' ,
229+ status : EscrowStatus . LOCKED ,
230+ amount : '100.0000000' ,
231+ asset : AssetType . USDC ,
232+ milestoneId : 'milestone-1' ,
233+ } ) ;
234+
235+ it ( 'releases part of a LOCKED escrow and records a Payment while it stays LOCKED below the total' , async ( ) => {
236+ const escrow = lockedEscrow ( ) ;
237+ escrowRepo . findOne . mockResolvedValue ( escrow ) ;
238+ paymentRepo . find . mockResolvedValue ( [ ] ) ;
239+
240+ const payment = await service . releasePartial (
241+ 'escrow-partial' ,
242+ '30.0000000' ,
243+ 'GRECIPIENT' ,
244+ 'user-1' ,
245+ ) ;
246+
247+ expect ( soroban . invoke ) . toHaveBeenCalledWith ( 'release' , [
248+ 'milestone-1' ,
249+ 'GRECIPIENT' ,
250+ 300_000_000n ,
251+ ] ) ;
252+ expect ( paymentRepo . save ) . toHaveBeenCalledWith (
253+ expect . objectContaining ( {
254+ escrowId : 'escrow-partial' ,
255+ recipientId : 'user-1' ,
256+ recipientAddress : 'GRECIPIENT' ,
257+ amount : '30.0000000' ,
258+ asset : AssetType . USDC ,
259+ status : PaymentStatus . CONFIRMED ,
260+ } ) ,
261+ ) ;
262+ expect ( payment . amount ) . toBe ( '30.0000000' ) ;
263+ expect ( escrow . status ) . toBe ( EscrowStatus . LOCKED ) ;
264+ expect ( escrowRepo . save ) . not . toHaveBeenCalled ( ) ;
265+ } ) ;
266+
267+ it ( 'flips the escrow to RELEASED when a single partial release covers the full amount' , async ( ) => {
268+ escrowRepo . findOne . mockResolvedValue ( lockedEscrow ( ) ) ;
269+ paymentRepo . find . mockResolvedValue ( [ ] ) ;
270+
271+ await service . releasePartial (
272+ 'escrow-partial' ,
273+ '100.0000000' ,
274+ 'GRECIPIENT' ,
275+ ) ;
276+
277+ expect ( escrowRepo . save ) . toHaveBeenCalledWith (
278+ expect . objectContaining ( {
279+ id : 'escrow-partial' ,
280+ status : EscrowStatus . RELEASED ,
281+ releaseTxHash : 'tx-hash-123' ,
282+ } ) ,
283+ ) ;
284+ } ) ;
285+
286+ it ( 'completes a partial-then-partial sequence only once the cumulative total reaches the amount' , async ( ) => {
287+ escrowRepo . findOne . mockResolvedValue ( lockedEscrow ( ) ) ;
288+ paymentRepo . find
289+ . mockResolvedValueOnce ( [ ] )
290+ . mockResolvedValueOnce ( [ { amount : '40.0000000' } ] ) ;
291+
292+ await service . releasePartial ( 'escrow-partial' , '40.0000000' , 'GA' ) ;
293+ expect ( escrowRepo . save ) . not . toHaveBeenCalled ( ) ;
294+
295+ await service . releasePartial ( 'escrow-partial' , '60.0000000' , 'GB' ) ;
296+
297+ expect ( soroban . invoke ) . toHaveBeenNthCalledWith ( 2 , 'release' , [
298+ 'milestone-1' ,
299+ 'GB' ,
300+ 600_000_000n ,
301+ ] ) ;
302+ expect ( escrowRepo . save ) . toHaveBeenCalledWith (
303+ expect . objectContaining ( {
304+ status : EscrowStatus . RELEASED ,
305+ releasedAt : expect . any ( Date ) ,
306+ } ) ,
307+ ) ;
308+ } ) ;
309+
310+ it ( 'rejects a release that would exceed the remaining balance after prior partials' , async ( ) => {
311+ escrowRepo . findOne . mockResolvedValue ( lockedEscrow ( ) ) ;
312+ paymentRepo . find . mockResolvedValue ( [ { amount : '50.0000000' } ] ) ;
313+
314+ await expect (
315+ service . releasePartial ( 'escrow-partial' , '60.0000000' , 'GRECIPIENT' ) ,
316+ ) . rejects . toThrow ( BadRequestException ) ;
317+
318+ expect ( soroban . invoke ) . not . toHaveBeenCalled ( ) ;
319+ expect ( paymentRepo . save ) . not . toHaveBeenCalled ( ) ;
320+ expect ( escrowRepo . save ) . not . toHaveBeenCalled ( ) ;
321+ } ) ;
322+ } ) ;
323+
324+ describe ( 'refund' , ( ) => {
325+ it ( 'rejects refunding an escrow that is not LOCKED' , async ( ) => {
326+ escrowRepo . findOne . mockResolvedValue ( {
327+ id : 'escrow-pending' ,
328+ status : EscrowStatus . PENDING ,
329+ amount : '10' ,
330+ asset : AssetType . USDC ,
331+ } ) ;
332+
333+ await expect ( service . refund ( 'escrow-pending' ) ) . rejects . toThrow (
334+ BadRequestException ,
335+ ) ;
336+ expect ( soroban . invoke ) . not . toHaveBeenCalled ( ) ;
337+ } ) ;
338+
339+ it ( 'refunds a LOCKED escrow to the original funder' , async ( ) => {
340+ escrowRepo . findOne . mockResolvedValue ( {
341+ id : 'escrow-refund' ,
342+ status : EscrowStatus . LOCKED ,
343+ amount : '25.0000000' ,
344+ asset : AssetType . USDC ,
345+ bountyId : 'bounty-7' ,
346+ } ) ;
347+
348+ const escrow = await service . refund ( 'escrow-refund' ) ;
349+
350+ expect ( soroban . invoke ) . toHaveBeenCalledWith ( 'refund' , [ 'bounty-7' ] ) ;
351+ expect ( escrow . status ) . toBe ( EscrowStatus . REFUNDED ) ;
352+ expect ( escrow . refundTxHash ) . toBe ( 'tx-hash-123' ) ;
353+ expect ( escrow . refundedAt ) . toBeInstanceOf ( Date ) ;
354+ } ) ;
355+ } ) ;
356+
221357 describe ( 'assertValidSplits / splitRelease' , ( ) => {
222358 it ( 'throws when percentages do not sum to 100' , ( ) => {
223359 expect ( ( ) =>
0 commit comments