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
15 changes: 15 additions & 0 deletions src/milestones/milestones.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
24 changes: 24 additions & 0 deletions src/milestones/milestones.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Milestone> {
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
Expand Down