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