From 73c410881810c3d32d8b2d15965d37493861306a Mon Sep 17 00:00:00 2001 From: Yggdrasil Date: Wed, 14 Oct 2026 03:03:05 -0400 Subject: [PATCH] =?UTF-8?q?feat(milestones):=20POST=20/:id/close=20?= =?UTF-8?q?=E2=80=94=20close=20+=20refund=20the=20unspent=20balance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MilestoneStatus.CLOSED existed and the state machine allowed (OPEN|FUNDED|IN_PROGRESS)->CLOSED, but no endpoint reached it — a sponsor had no way to reclaim an unspent milestone budget. Sponsor-only close: asserts the transition and refunds the remaining escrow. Resolves #587 --- src/milestones/milestones.controller.ts | 15 +++++++++++++++ src/milestones/milestones.service.ts | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index 0aaf9d99..c6c30245 100644 --- a/src/milestones/milestones.controller.ts +++ b/src/milestones/milestones.controller.ts @@ -143,4 +143,19 @@ export class MilestonesController { allocateBudget(@Param('id', new ParseUUIDPipe()) id: string) { return this.milestonesService.allocateBudget(id); } + + @ApiOperation({ + summary: 'Close a milestone and refund its unspent balance', + }) + @ApiBearerAuth() + @ApiStandardErrorResponses() + @UseGuards(JwtAuthGuard) + @Post(':id/close') + close(@Param('id', new ParseUUIDPipe()) id: string, @Req() req: Request) { + // There was no way to reach MilestoneStatus.CLOSED or refund the unspent + // escrow — a sponsor couldn't reclaim an unspent budget (#587). Sponsor- + // only; the service enforces it. + const userId = (req.user as { userId?: string }).userId; + return this.milestonesService.close(id, userId); + } } diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index c2b7ee8b..7ed7399a 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -130,6 +130,30 @@ export class MilestonesService { return this.issueRepo.save(issue); } + /** + * Sponsor-only close — transitions the milestone to CLOSED and refunds the + * escrow's remaining unspent balance. MilestoneStatus.CLOSED existed and the + * state machine allowed (OPEN|FUNDED|IN_PROGRESS) -> CLOSED, but no service/ + * controller path reached it (#587) — a sponsor had no way to reclaim the + * unspent part of a milestone budget. + */ + async close(milestoneId: string, callerUserId: string): Promise { + const milestone = await this.findOne(milestoneId); + if (milestone.sponsorId && milestone.sponsorId !== callerUserId) { + throw new ForbiddenException('Only the milestone sponsor can close it'); + } + assertTransition(milestone.status, MilestoneStatus.CLOSED); + + // Refund the remaining escrow balance — the sponsor keeps what wasn't + // distributed. releasePartial paid the contributors; refund returns the + // rest (#587). + if (milestone.escrowId) { + await this.escrowService.refund(milestone.escrowId); + } + milestone.status = MilestoneStatus.CLOSED; + return this.milestoneRepo.save(milestone); + } + /** * Distributes this milestone's budget proportionally as its issues resolve. * TODO: support per-issue weighted budgets; currently splits the remaining