479 lines
20 KiB
Go
479 lines
20 KiB
Go
package financewithdrawal
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
"hyapp-admin-server/internal/integration/activityclient"
|
||
"hyapp-admin-server/internal/integration/walletclient"
|
||
"hyapp-admin-server/internal/model"
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
"hyapp-admin-server/internal/repository"
|
||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
)
|
||
|
||
const (
|
||
permissionViewWithdrawalApplications = "finance-withdrawal:view"
|
||
permissionAuditWithdrawalApplications = "finance-withdrawal:audit"
|
||
permissionViewOperationsWithdrawals = "operations-withdrawal:view"
|
||
permissionAuditOperationsWithdrawals = "operations-withdrawal:audit"
|
||
|
||
pointWithdrawalDefaultPointsPerUSD = int64(100000)
|
||
pointWithdrawalDefaultFeeBPS = int32(500)
|
||
)
|
||
|
||
var errWithdrawalMoneyScopeForbidden = errors.New("没有该 App 的财务范围")
|
||
|
||
type Service struct {
|
||
store *repository.Store
|
||
wallet walletclient.Client
|
||
activity activityclient.Client
|
||
now func() time.Time
|
||
}
|
||
|
||
type pointWithdrawalWallet interface {
|
||
SettlePointWithdrawal(ctx context.Context, req *walletv1.SettlePointWithdrawalRequest) (*walletv1.SettlePointWithdrawalResponse, error)
|
||
ReleasePointWithdrawal(ctx context.Context, req *walletv1.ReleasePointWithdrawalRequest) (*walletv1.ReleasePointWithdrawalResponse, error)
|
||
}
|
||
|
||
func NewService(store *repository.Store, wallet walletclient.Client, activity activityclient.Client) *Service {
|
||
return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }}
|
||
}
|
||
|
||
func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
|
||
if s == nil || s.store == nil {
|
||
return nil, 0, errors.New("admin store is not configured")
|
||
}
|
||
var err error
|
||
options, err = s.scopeWithdrawalList(actor, options)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
options.Stage = repository.WithdrawalApplicationReviewStageFinance
|
||
items, total, err := s.store.ListWithdrawalApplications(options)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return withdrawalApplicationDTOsFromModel(items), total, nil
|
||
}
|
||
|
||
func (s *Service) ListOperationsApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) {
|
||
if s == nil || s.store == nil {
|
||
return nil, 0, errors.New("admin store is not configured")
|
||
}
|
||
var err error
|
||
options, err = s.scopeWithdrawalList(actor, options)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
options.Stage = repository.WithdrawalApplicationReviewStageOperations
|
||
items, total, err := s.store.ListWithdrawalApplications(options)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
return withdrawalApplicationDTOsFromModel(items), total, nil
|
||
}
|
||
|
||
func (s *Service) scopeWithdrawalList(actor shared.Actor, options repository.WithdrawalApplicationListOptions) (repository.WithdrawalApplicationListOptions, error) {
|
||
access, err := s.store.MoneyAccessForUser(actor.UserID)
|
||
if err != nil {
|
||
return options, err
|
||
}
|
||
if requestedApp := strings.TrimSpace(options.AppCode); requestedApp != "" {
|
||
options.AppCode = appctx.Normalize(requestedApp)
|
||
if !access.AllowsApp(options.AppCode) {
|
||
return options, errWithdrawalMoneyScopeForbidden
|
||
}
|
||
return options, nil
|
||
}
|
||
options.AppCode = ""
|
||
if !access.All {
|
||
// 空 app 查询不代表全局;非全量财务用户只能在 admin_user_money_scopes 授权的 App 集合中分页。
|
||
options.RestrictAppCodes = true
|
||
options.AllowedAppCodes = access.AppCodes()
|
||
}
|
||
return options, nil
|
||
}
|
||
|
||
func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) {
|
||
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved, remark, auditImageURL, requestID)
|
||
}
|
||
|
||
func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
|
||
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusRejected, remark, "", requestID)
|
||
}
|
||
|
||
func (s *Service) ApproveOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
|
||
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusApproved, remark, "", requestID)
|
||
}
|
||
|
||
func (s *Service) RejectOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) {
|
||
return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected, remark, "", requestID)
|
||
}
|
||
|
||
func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, stage repository.WithdrawalApplicationReviewStage, decision string, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) {
|
||
if s == nil || s.store == nil || s.wallet == nil || s.activity == nil {
|
||
return nil, errors.New("admin finance withdrawal service is not configured")
|
||
}
|
||
if id == 0 || actor.UserID == 0 || !canAuditWithdrawalStage(stage, actor.Permissions) {
|
||
return nil, errors.New("没有操作权限")
|
||
}
|
||
remark = strings.TrimSpace(remark)
|
||
auditImageURL = strings.TrimSpace(auditImageURL)
|
||
if stage == repository.WithdrawalApplicationReviewStageFinance && decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" {
|
||
return nil, errors.New("提现通过凭证图片不能为空")
|
||
}
|
||
if decision == model.WithdrawalApplicationStatusRejected && remark == "" {
|
||
return nil, errors.New("拒绝原因不能为空")
|
||
}
|
||
appCode := appctx.FromContext(ctx)
|
||
access, err := s.store.MoneyAccessForUser(actor.UserID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !access.AllowsApp(appCode) {
|
||
// 审核以目标行的 App header 为数据域;即使 JWT 含按钮权限,也不能跨 admin_user_money_scopes 触发钱包动作。
|
||
return nil, errWithdrawalMoneyScopeForbidden
|
||
}
|
||
nowMS := s.now().UnixMilli()
|
||
commandID := withdrawalAuditCommandID(id, stage)
|
||
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusRejected {
|
||
return s.executeOperationsRejection(ctx, actor, id, appCode, remark, requestID, commandID, nowMS)
|
||
}
|
||
updated, err := s.store.ReviewWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
|
||
Stage: stage,
|
||
Decision: decision,
|
||
ApproverUserID: actor.UserID,
|
||
ApproverName: actor.Username,
|
||
AuditRemark: remark,
|
||
AuditImageURL: auditImageURL,
|
||
ApprovedAtMS: nowMS,
|
||
}, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) {
|
||
userID, parseErr := withdrawalWalletUserID(item)
|
||
if parseErr != nil {
|
||
return repository.WithdrawalApplicationAuditEffect{}, parseErr
|
||
}
|
||
if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved {
|
||
// 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen,且不能提前向用户发送最终通过通知。
|
||
return repository.WithdrawalApplicationAuditEffect{}, nil
|
||
}
|
||
transactionID, walletErr := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id)
|
||
if walletErr != nil {
|
||
return repository.WithdrawalApplicationAuditEffect{}, walletErr
|
||
}
|
||
if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
|
||
ApplicationID: id,
|
||
AppCode: appCode,
|
||
UserID: userID,
|
||
Stage: stage,
|
||
Decision: decision,
|
||
Remark: remark,
|
||
AuditImageURL: auditImageURL,
|
||
AuditTransactionID: transactionID,
|
||
WithdrawAmount: item.WithdrawAmount,
|
||
WithdrawMethod: item.WithdrawMethod,
|
||
RequestID: requestID,
|
||
SentAtMS: nowMS,
|
||
}); noticeErr != nil {
|
||
// 钱包扣冻/释放使用阶段固定 command id,通知使用阶段+结果事件 id;回调失败会回滚 admin 状态,重试不会重复资金动作或消息。
|
||
return repository.WithdrawalApplicationAuditEffect{}, noticeErr
|
||
}
|
||
return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dto := withdrawalApplicationDTOFromModel(*updated)
|
||
return &dto, nil
|
||
}
|
||
|
||
// executeOperationsRejection 把拒绝拆成 claim -> 外部幂等动作 -> finalize 三段。
|
||
// claim 是独立短事务,因此 wallet 已释放后即使 notice 或最终 admin 提交失败,申请仍停在 rejecting,不能被改点通过或进入财务。
|
||
func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.Actor, id uint, appCode string, remark string, requestID string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) {
|
||
claimed, err := s.store.ClaimWithdrawalOperationsRejectionForApp(appCode, id, repository.WithdrawalApplicationAuditInput{
|
||
Stage: repository.WithdrawalApplicationReviewStageOperations,
|
||
Decision: model.WithdrawalApplicationStatusRejected,
|
||
ApproverUserID: actor.UserID,
|
||
ApproverName: actor.Username,
|
||
AuditRemark: remark,
|
||
AuditCommandID: commandID,
|
||
ApprovedAtMS: nowMS,
|
||
}, func(item model.UserWithdrawalApplication) error {
|
||
_, err := withdrawalWalletUserID(item)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if claimed.Status == model.WithdrawalApplicationStatusRejected && claimed.OperationsStatus == model.WithdrawalOperationsStatusRejected {
|
||
// 已完成的重复请求直接返回终态,避免再次调用 wallet/inbox;前端重试是安全的。
|
||
dto := withdrawalApplicationDTOFromModel(*claimed)
|
||
return &dto, nil
|
||
}
|
||
|
||
userID, err := withdrawalWalletUserID(*claimed)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" {
|
||
return nil, errors.New("提现单运营拒绝 claim 不完整")
|
||
}
|
||
// 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。
|
||
claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName}
|
||
claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark)
|
||
transactionID, err := s.applyWalletDecision(ctx, model.WithdrawalApplicationStatusRejected, commandID, appCode, userID, claimed.SalaryAssetType, claimed.WithdrawAmountMinor, claimed.PointFeeAmount, claimed.PointNetAmount, claimed.PointsPerUSD, claimed.PointFeeBPS, claimedActor, claimedRemark, id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
sentAtMS := nowMS
|
||
if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 {
|
||
sentAtMS = *claimed.OperationsReviewedAtMS
|
||
}
|
||
if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{
|
||
ApplicationID: id,
|
||
AppCode: appCode,
|
||
UserID: userID,
|
||
Stage: repository.WithdrawalApplicationReviewStageOperations,
|
||
Decision: model.WithdrawalApplicationStatusRejected,
|
||
Remark: claimedRemark,
|
||
AuditTransactionID: transactionID,
|
||
WithdrawAmount: claimed.WithdrawAmount,
|
||
WithdrawMethod: claimed.WithdrawMethod,
|
||
RequestID: requestID,
|
||
SentAtMS: sentAtMS,
|
||
}); err != nil {
|
||
// rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。
|
||
return nil, err
|
||
}
|
||
updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
dto := withdrawalApplicationDTOFromModel(*updated)
|
||
return &dto, nil
|
||
}
|
||
|
||
func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error) {
|
||
userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64)
|
||
if err != nil || userID <= 0 || strings.TrimSpace(item.SalaryAssetType) == "" || item.WithdrawAmountMinor <= 0 || strings.TrimSpace(item.FreezeTransactionID) == "" {
|
||
return 0, errors.New("提现单冻结信息不完整")
|
||
}
|
||
return userID, nil
|
||
}
|
||
|
||
func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) {
|
||
reason := strings.TrimSpace(remark)
|
||
if reason == "" {
|
||
reason = "salary withdrawal " + decision
|
||
}
|
||
applicationIDText := strconv.FormatUint(uint64(applicationID), 10)
|
||
if isPointWithdrawalAssetType(assetType) {
|
||
pointWallet, ok := s.wallet.(pointWithdrawalWallet)
|
||
if !ok {
|
||
return "", errors.New("point withdrawal wallet executor is not configured")
|
||
}
|
||
feePoints, netPoints, pointsPerUSD, feeBPS := pointWithdrawalSnapshot(model.UserWithdrawalApplication{
|
||
WithdrawAmountMinor: amountMinor, PointFeeAmount: storedFeePoints, PointNetAmount: storedNetPoints,
|
||
PointsPerUSD: storedPointsPerUSD, PointFeeBPS: storedFeeBPS,
|
||
})
|
||
switch decision {
|
||
case model.WithdrawalApplicationStatusApproved:
|
||
resp, err := pointWallet.SettlePointWithdrawal(ctx, &walletv1.SettlePointWithdrawalRequest{
|
||
CommandId: commandID,
|
||
UserId: userID,
|
||
AssetType: assetType,
|
||
GrossPointAmount: amountMinor,
|
||
FeePointAmount: feePoints,
|
||
NetPointAmount: netPoints,
|
||
PointsPerUsd: pointsPerUSD,
|
||
FeeBps: feeBPS,
|
||
OperatorUserId: int64(actor.UserID),
|
||
Reason: reason,
|
||
AppCode: appCode,
|
||
WithdrawalApplicationId: applicationIDText,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return resp.GetTransactionId(), nil
|
||
case model.WithdrawalApplicationStatusRejected:
|
||
resp, err := pointWallet.ReleasePointWithdrawal(ctx, &walletv1.ReleasePointWithdrawalRequest{
|
||
CommandId: commandID,
|
||
UserId: userID,
|
||
AssetType: assetType,
|
||
GrossPointAmount: amountMinor,
|
||
FeePointAmount: feePoints,
|
||
NetPointAmount: netPoints,
|
||
PointsPerUsd: pointsPerUSD,
|
||
FeeBps: feeBPS,
|
||
OperatorUserId: int64(actor.UserID),
|
||
Reason: reason,
|
||
AppCode: appCode,
|
||
WithdrawalApplicationId: applicationIDText,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return resp.GetTransactionId(), nil
|
||
default:
|
||
return "", errors.New("审核结果不正确")
|
||
}
|
||
}
|
||
switch decision {
|
||
case model.WithdrawalApplicationStatusApproved:
|
||
resp, err := s.wallet.SettleSalaryWithdrawal(ctx, &walletv1.SettleSalaryWithdrawalRequest{
|
||
CommandId: commandID,
|
||
UserId: userID,
|
||
SalaryAssetType: assetType,
|
||
SalaryUsdMinor: amountMinor,
|
||
OperatorUserId: int64(actor.UserID),
|
||
Reason: reason,
|
||
AppCode: appCode,
|
||
WithdrawalApplicationId: applicationIDText,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return resp.GetTransactionId(), nil
|
||
case model.WithdrawalApplicationStatusRejected:
|
||
resp, err := s.wallet.ReleaseSalaryWithdrawal(ctx, &walletv1.ReleaseSalaryWithdrawalRequest{
|
||
CommandId: commandID,
|
||
UserId: userID,
|
||
SalaryAssetType: assetType,
|
||
SalaryUsdMinor: amountMinor,
|
||
OperatorUserId: int64(actor.UserID),
|
||
Reason: reason,
|
||
AppCode: appCode,
|
||
WithdrawalApplicationId: applicationIDText,
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return resp.GetTransactionId(), nil
|
||
default:
|
||
return "", errors.New("审核结果不正确")
|
||
}
|
||
}
|
||
|
||
func withdrawalAuditCommandID(id uint, stage repository.WithdrawalApplicationReviewStage) string {
|
||
// 同一阶段的通过和拒绝复用 command id;并发相反决策会在 wallet request hash 校验处冲突,不能先后 settle/release 同一冻结金额。
|
||
return fmt.Sprintf("salary-withdrawal:%d:%s", id, stage)
|
||
}
|
||
|
||
func isPointWithdrawalAssetType(assetType string) bool {
|
||
switch strings.ToUpper(strings.TrimSpace(assetType)) {
|
||
case "POINT", "COIN_SELLER_POINT":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
type auditNoticeInput struct {
|
||
ApplicationID uint
|
||
AppCode string
|
||
UserID int64
|
||
Stage repository.WithdrawalApplicationReviewStage
|
||
Decision string
|
||
Remark string
|
||
AuditImageURL string
|
||
AuditTransactionID string
|
||
WithdrawAmount string
|
||
WithdrawMethod string
|
||
RequestID string
|
||
SentAtMS int64
|
||
}
|
||
|
||
func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoticeInput) error {
|
||
body := "Your withdrawal request has been approved."
|
||
title := "Withdrawal request approved"
|
||
imageURL := ""
|
||
eventType := "finance_withdrawal_approved"
|
||
if input.Decision == model.WithdrawalApplicationStatusRejected {
|
||
body = "Your withdrawal request has been rejected. Reason: " + strings.TrimSpace(input.Remark)
|
||
title = "Withdrawal request rejected"
|
||
eventType = "finance_withdrawal_rejected"
|
||
if input.Stage == repository.WithdrawalApplicationReviewStageOperations {
|
||
eventType = "operations_withdrawal_rejected"
|
||
}
|
||
} else {
|
||
imageURL = strings.TrimSpace(input.AuditImageURL)
|
||
}
|
||
metadata, err := json.Marshal(map[string]any{
|
||
"application_id": input.ApplicationID,
|
||
"app_code": strings.TrimSpace(input.AppCode),
|
||
"audit_transaction_id": strings.TrimSpace(input.AuditTransactionID),
|
||
"decision": strings.TrimSpace(input.Decision),
|
||
"review_stage": string(input.Stage),
|
||
"withdraw_amount": strings.TrimSpace(input.WithdrawAmount),
|
||
"withdraw_method": strings.TrimSpace(input.WithdrawMethod),
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = s.activity.CreateInboxMessage(ctx, &activityv1.CreateInboxMessageRequest{
|
||
Meta: &activityv1.RequestMeta{
|
||
RequestId: strings.TrimSpace(input.RequestID),
|
||
Caller: "admin-server",
|
||
AppCode: strings.TrimSpace(input.AppCode),
|
||
SentAtMs: input.SentAtMS,
|
||
},
|
||
TargetUserId: input.UserID,
|
||
Producer: "admin-server",
|
||
ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Stage, input.Decision),
|
||
ProducerEventType: eventType,
|
||
MessageType: "system",
|
||
AggregateType: "user_withdrawal_application",
|
||
AggregateId: strconv.FormatUint(uint64(input.ApplicationID), 10),
|
||
Title: title,
|
||
Summary: body,
|
||
Body: body,
|
||
ImageUrl: imageURL,
|
||
SentAtMs: input.SentAtMS,
|
||
MetadataJson: string(metadata),
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("发送提现审核通知失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func withdrawalAuditNotificationEventID(id uint, stage repository.WithdrawalApplicationReviewStage, decision string) string {
|
||
if stage == repository.WithdrawalApplicationReviewStageFinance {
|
||
// 旧财务通知可能已成功而 admin 申请仍 pending;继续使用原 event id,部署后重试才不会重复发用户通知。
|
||
return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision))
|
||
}
|
||
return fmt.Sprintf("operations-withdrawal:%d:%s", id, strings.TrimSpace(decision))
|
||
}
|
||
|
||
func canAuditWithdrawal(permissions []string) bool {
|
||
return hasWithdrawalPermission(permissions, permissionAuditWithdrawalApplications)
|
||
}
|
||
|
||
func canAuditOperationsWithdrawal(permissions []string) bool {
|
||
return hasWithdrawalPermission(permissions, permissionAuditOperationsWithdrawals)
|
||
}
|
||
|
||
func canAuditWithdrawalStage(stage repository.WithdrawalApplicationReviewStage, permissions []string) bool {
|
||
if stage == repository.WithdrawalApplicationReviewStageOperations {
|
||
return canAuditOperationsWithdrawal(permissions)
|
||
}
|
||
return canAuditWithdrawal(permissions)
|
||
}
|
||
|
||
func hasWithdrawalPermission(permissions []string, expected string) bool {
|
||
for _, permission := range permissions {
|
||
if strings.TrimSpace(permission) == expected {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|