diff --git a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java index 2cacd19..bb0e774 100644 --- a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java @@ -3,6 +3,7 @@ package com.ga.api.controller.mobile; import com.ga.api.service.mobile.MobileMeService; import com.ga.api.service.mobile.MobileMeService.MeSummary; import com.ga.api.service.mobile.MobileMeService.MobileWithdrawSaveReq; +import com.ga.api.service.mobile.MobileMeService.WithdrawableBalance; import com.ga.common.model.ApiResponse; import com.ga.common.model.PageResponse; import com.ga.core.vo.notice.UserNotificationResp; @@ -69,6 +70,11 @@ public class MobileMeController { return ApiResponse.ok(service.withdrawableSettleMasters()); } + @GetMapping("/settle-masters/{settleMasterId}/withdrawable-balance") + public ApiResponse withdrawableBalance(@PathVariable Long settleMasterId) { + return ApiResponse.ok(service.withdrawableBalance(settleMasterId)); + } + @GetMapping("/withdraw-requests") public ApiResponse> withdrawRequests( @ModelAttribute WithdrawRequestSearchParam param) { diff --git a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java index 654090a..2812e1f 100644 --- a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java +++ b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java @@ -168,6 +168,28 @@ public class MobileMeService { withdrawRequestService.cancel(requestId); } + /** + * 본인 정산 마스터의 출금 가능 잔액 조회 — 출금 신청 폼 미리 표시용. + * 본인 소유가 아니면 FORBIDDEN, 없으면 NOT_FOUND. + */ + @Transactional(readOnly = true) + public WithdrawableBalance withdrawableBalance(Long settleMasterId) { + Long agentId = requireAgentId(); + var master = settleMasterMapper.selectById(settleMasterId); + if (master == null) throw new BizException(ErrorCode.NOT_FOUND, "정산 마스터를 찾을 수 없습니다"); + if (!agentId.equals(master.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN); + + BigDecimal netAmount = master.getNetAmount() != null ? master.getNetAmount() : BigDecimal.ZERO; + BigDecimal used = withdrawMapper.sumActiveAmountByMaster(settleMasterId); + if (used == null) used = BigDecimal.ZERO; + return WithdrawableBalance.builder() + .settleId(settleMasterId) + .netAmount(netAmount) + .usedAmount(used) + .remainingAmount(netAmount.subtract(used)) + .build(); + } + /** * 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정. * 클라이언트가 보낸 값은 무시한다. @@ -175,22 +197,14 @@ public class MobileMeService { @Transactional public Long createWithdrawRequest(MobileWithdrawSaveReq req) { Long userId = currentUserId(); - Long agentId = requireAgentId(); - // 1) settle_master 본인 소유 + net_amount 확보 - var master = settleMasterMapper.selectById(req.getSettleMasterId()); - if (master == null) throw new BizException(ErrorCode.NOT_FOUND, "정산 마스터를 찾을 수 없습니다"); - if (!agentId.equals(master.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN); - - // 2) 잔여 한도 = net_amount - 활성 출금합계 - BigDecimal netAmount = master.getNetAmount() != null ? master.getNetAmount() : BigDecimal.ZERO; - BigDecimal used = withdrawMapper.sumActiveAmountByMaster(req.getSettleMasterId()); - if (used == null) used = BigDecimal.ZERO; - BigDecimal remaining = netAmount.subtract(used); - if (req.getAmount().compareTo(remaining) > 0) { + // 본인 소유 검증 + 잔여 한도 = net_amount - 활성 출금합계 (조회 로직 재사용) + WithdrawableBalance balance = withdrawableBalance(req.getSettleMasterId()); + if (req.getAmount().compareTo(balance.getRemainingAmount()) > 0) { throw new BizException(ErrorCode.INVALID_PARAMETER, - "출금 가능 잔액(" + remaining + ")을 초과합니다"); + "출금 가능 잔액(" + balance.getRemainingAmount() + ")을 초과합니다"); } + Long agentId = requireAgentId(); WithdrawRequestSaveReq inner = new WithdrawRequestSaveReq(); inner.setSettleMasterId(req.getSettleMasterId()); @@ -234,6 +248,16 @@ public class MobileMeService { private int unreadNotificationCount; } + /** 출금 가능 잔액 — 폼 미리 표시용. */ + @Data + @Builder + public static class WithdrawableBalance { + private Long settleId; + private BigDecimal netAmount; + private BigDecimal usedAmount; + private BigDecimal remainingAmount; + } + /** 모바일 출금 신청 — 토큰에서 agentId/requestedBy 강제. */ @Data public static class MobileWithdrawSaveReq { diff --git a/ga-mobile-pwa/src/api/withdraw.ts b/ga-mobile-pwa/src/api/withdraw.ts index e48b622..264e2f9 100644 --- a/ga-mobile-pwa/src/api/withdraw.ts +++ b/ga-mobile-pwa/src/api/withdraw.ts @@ -41,6 +41,13 @@ export interface WithdrawCreateReq { accountNo: string; } +export interface WithdrawableBalance { + settleId: number; + netAmount: number; + usedAmount: number; + remainingAmount: number; +} + export const withdrawApi = { /** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */ list: (params: WithdrawSearchParam = {}) => @@ -50,6 +57,12 @@ export const withdrawApi = { settleMasters: () => unwrap(api.get('/api/mobile/me/settle-masters')), + /** 선택한 정산 마스터의 출금 가능 잔액 (net - 활성 출금합계). */ + withdrawableBalance: (settleMasterId: number) => + unwrap( + api.get(`/api/mobile/me/settle-masters/${settleMasterId}/withdrawable-balance`), + ), + /** 본인 출금 신청 등록. */ create: (req: WithdrawCreateReq) => unwrap(api.post('/api/mobile/me/withdraw-requests', req)), diff --git a/ga-mobile-pwa/src/pages/WithdrawCreate.tsx b/ga-mobile-pwa/src/pages/WithdrawCreate.tsx index c5b1294..d034b2c 100644 --- a/ga-mobile-pwa/src/pages/WithdrawCreate.tsx +++ b/ga-mobile-pwa/src/pages/WithdrawCreate.tsx @@ -18,6 +18,7 @@ export default function WithdrawCreate() { const [form] = Form.useForm(); const [pickerVisible, setPickerVisible] = useState(false); const [pickedLabel, setPickedLabel] = useState(''); + const [selectedId, setSelectedId] = useState(undefined); const { data: settleMasters = [], isLoading } = useQuery({ queryKey: ['mobile.me.settle-masters'], @@ -25,6 +26,13 @@ export default function WithdrawCreate() { retry: false, }); + const { data: balance } = useQuery({ + queryKey: ['mobile.me.withdrawable-balance', selectedId], + queryFn: () => withdrawApi.withdrawableBalance(selectedId as number), + enabled: selectedId != null, + retry: false, + }); + const pickerColumns: PickerColumn[] = useMemo( () => [ settleMasters.map((s: SettleMasterResp) => ({ @@ -56,6 +64,10 @@ export default function WithdrawCreate() { Toast.show({ icon: 'fail', content: '금액을 입력하세요' }); return; } + if (balance && amount > balance.remainingAmount) { + Toast.show({ icon: 'fail', content: `출금 가능 잔액(${fmt(balance.remainingAmount)}원)을 초과합니다` }); + return; + } create.mutate({ settleMasterId: Number(values.settleMasterId), amount, @@ -67,6 +79,25 @@ export default function WithdrawCreate() { return (
navigate(-1)}>출금 신청 + {balance && ( +
+
출금 가능 잔액
+
+ {fmt(balance.remainingAmount)}원 +
+
+ 실수령 {fmt(balance.netAmount)}원 · 신청중 {fmt(balance.usedAmount)}원 +
+
+ )}
{ const id = val[0] != null ? Number(val[0]) : undefined; form.setFieldValue('settleMasterId', id); + setSelectedId(id); const picked = settleMasters.find((s) => String(s.settleId) === val[0]); if (picked) setPickedLabel(`${picked.settleMonth} · 실수령 ${fmt(picked.netAmount)}원`); }}