36 Commits

Author SHA1 Message Date
masoodafar-web ea7ba3bd3f Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 16m3s
2026-09-24 21:46:12 +03:30
masoodafar-web fe7179f6e9 chore(k8s): deploy staging apps to namespace kbs-stage
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 21m11s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 21:26:15 +03:30
masoodafar-web f79affb44d feat(transactions): enhance transaction queries with user information
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 21m45s
- Added UserFullName and UserId properties to transaction response models for better user context.
- Updated query handlers to populate user information from related entities, ensuring accurate user details in transaction responses.
- Modified Protobuf definitions to include new fields for user identification and display.
- Adjusted mapping profiles to accommodate new user-related fields in transaction data.
2026-09-24 19:28:01 +03:30
masoodafar-web 749cfeb8f4 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 15:12:58 +03:30
masoodafar-web 6f573c0a29 fix(payment): set TwentyCard package amounts and send rials to gateway
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2s
Gateway 11,200,000 toman and wallet credit 12,656,000 toman; multiply by 10 when posting to TwentyCard API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 15:12:08 +03:30
masoodafar-web 9b82b45e46 chore(staging): point registry and K8S to 185.252.31.44 after server migration
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 50s
2026-09-22 22:18:43 +03:30
masoodafar-web 39bbe59bbc fix(config): resolve appsettings.json merge conflict markers on production
Build and Deploy to Production / build-and-deploy (push) Failing after 1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 21:21:09 +03:30
masoodafar-web 5c852e499d Merge branch 'kub-stage' into production
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 21:20:39 +03:30
masoodafar-web cbc1225562 fix(payment): update JSON serialization settings and enhance request data structure
- Changed PropertyNamingPolicy in TwentyCardPaymentService from SnakeCaseLower to CamelCase for improved consistency in JSON serialization.
- Added JsonPropertyName attributes for Authority and RedirectUrl properties in TwentyCardRequestData to ensure correct JSON mapping.
2026-09-22 00:59:39 +03:30
masoodafar-web 6ad1341754 feat(config): add DefaultEmail to configuration files for payment processing
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 20h51m56s
- Introduced DefaultEmail field in cms-config.yaml, appsettings.json, and appsettings.Staging.json to standardize email handling in payment requests.
- Updated TwentyCardPaymentService to utilize DefaultEmail when no email is provided in the request, enhancing error handling and user experience.
2026-09-22 00:08:09 +03:30
masoodafar-web e970e516ce fix(config): update GatewayAmountToman values in configuration files
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m0s
- Changed GatewayAmountToman values from 9700000 and 97000000 to 1000 in both cms-config.yaml and appsettings.Staging.json for consistency in payment processing configurations.
2026-09-21 21:37:45 +03:30
masoodafar-web 9d17f174af feat(config): add CallbackBaseUrl to configuration for payment processing
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced CallbackBaseUrl in both cms-config.yaml and appsettings.Staging.json to support dynamic callback URLs for payment processing.
- Updated PackageService to utilize the new CallbackBaseUrl for enhanced security and flexibility in payment integration.
2026-09-21 21:35:42 +03:30
masoodafar-web 29426022aa feat(payment): enhance TwentyCard payment integration and update configuration
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Added support for optional Email and NationalId fields in PaymentRequest for TwentyCard transactions.
- Updated TwentyCardPaymentService to handle multipart/form-data requests and improved error handling.
- Modified appsettings and configuration files for both production and staging environments to include updated BaseUrl and credentials for TwentyCard.
- Updated README to reflect the new documentation location.
2026-09-21 21:31:17 +03:30
masoodafar-web 2b958bd214 feat(user): add user invitation functionality and enhance OTP verification
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m27s
- Introduced UserInvitation entity and updated IApplicationDbContext to include UserInvitations DbSet.
- Enhanced VerifyOtpTokenCommand and its handler to support optional inviter mobile number for user registration.
- Added new Protobuf definitions for GetMyInvitees and GetInvitationsByFilter RPCs to manage user invitations.
- Updated UserService to implement methods for retrieving invitees and invitations based on filters.
- Bumped Protobuf project version to reflect the addition of new features.
2026-09-20 23:33:03 +03:30
masoodafar-web f31fb5ee39 chore(config): update TwentyCard payment configuration for production and staging environments
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 11m25s
- Changed BaseUrl for TwentyCard from sandbox to production gateway.
- Added TerminalId, TerminalUsername, and TerminalPassword for TwentyCard payment processing in both appsettings.json and appsettings.Staging.json.
2026-09-20 22:30:39 +03:30
masoodafar-web 0b7212ac1c feat(payment): implement multi-gateway support and enhance payment processing
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m44s
- Added support for multiple payment gateways, including ZarinPal and TwentyCard, through a new IPaymentGatewayResolver.
- Updated PaymentRequest DTO to include payer name and invoice number for better transaction tracking.
- Enhanced PackageService to dynamically determine payment amounts based on the selected gateway.
- Updated appsettings to include configuration for TwentyCard payment options.
- Bumped Protobuf project version to reflect the addition of new features and changes.
2026-09-20 22:17:55 +03:30
masoodafar-web 59ed3857d4 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 7m11s
2026-09-04 07:25:35 +03:30
masoodafar-web 78d1fee89a refactor(network): improve network statistics calculations and update related DTOs
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m14s
- Refactored GetNetworkStatisticsQueryHandler to enhance logic for counting active members in left and right legs.
- Updated GetNetworkStatisticsResponseDto to include new properties for LeftLegCount and RightLegCount.
- Modified Protobuf definitions to align with the updated response structure for network statistics.
- Enhanced SQL script to differentiate between all subtree counts and active club member counts.
2026-09-04 04:12:31 +03:30
masoodafar-web 612d6fd141 fix(network): update network statistics and tree documentation
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m15s
- Updated README to reflect the new documentation location.
- Refactored GetNetworkStatisticsQueryHandler to improve method naming and logic for loading package holder counts.
- Enhanced GetNetworkStatisticsResponseDto and NetworkTreeDto to clarify the purpose of leg total properties.
- Updated Protobuf definitions to align with the new response structure for network statistics.
- Removed obsolete SQL scripts related to user package purchases and DayaLoan contracts cleanup.
2026-08-28 18:55:48 +03:30
masoodafar-web 87d61abc5b feat(network): add latest package leg totals to network statistics response
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m51s
- Introduced new properties for GoldLeftLegTotal, GoldRightLegTotal, SilverLeftLegTotal, and SilverRightLegTotal in GetNetworkStatisticsResponseDto.
- Implemented LoadLatestPackageLegScoresAsync method in GetNetworkStatisticsQueryHandler to fetch the latest leg totals for the user.
- Updated Protobuf definitions to include new fields for leg totals in GetMyNetworkStatisticsResponse.
- Bumped Protobuf project version to reflect the addition of new features.
2026-08-27 21:28:03 +03:30
masoodafar-web ea1d2d4400 feat(network): enrich network tree with package leg scores and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 12m47s
- Added logic to enrich the network tree with the latest left/right leg totals for gold and silver packages in GetNetworkTreeQueryHandler.
- Introduced new properties in NetworkTreeDto for GoldLeftLegTotal, GoldRightLegTotal, SilverLeftLegTotal, and SilverRightLegTotal.
- Updated Protobuf definitions to include new fields for leg scores in NetworkTreeNodeModel.
- Enhanced mapping in NetworkMembershipProfile and NetworkMembershipService to accommodate new leg score properties.
- Bumped Protobuf project version to reflect the addition of new features.
2026-08-25 22:39:19 +03:30
masoodafar-web c814cd9118 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 8m2s
2026-08-24 00:10:39 +03:30
masoodafar-web 2d23dbc798 feat(withdrawal): normalize IBAN format in withdrawal requests
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m38s
- Added IbanNormalizer to validate and normalize IBAN numbers in RequestWithdrawalCommandHandler and UserWalletService.
- Implemented error handling for invalid IBAN formats, ensuring compliance with expected standards.
- Updated relevant methods to handle normalized IBANs for cash withdrawal requests.
2026-08-23 23:01:17 +03:30
masoodafar-web 8ec0910cdf feat(jwt): enhance JWT token generation with user address check
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m36s
- Updated GenerateJwtTokenService to include IApplicationDbContext for accessing user addresses.
- Added logic to check if a user has an address and included this information as a claim in the generated JWT token.
- Improved overall token generation process by incorporating additional user data for better context.
2026-08-23 22:32:36 +03:30
masoodafar-web e882f09955 feat(user): enhance GetAllUserByFilter query response with additional fields and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m6s
- Added DefaultAddress and DefaultPostalCode properties to GetAllUserByFilterResponseDto for improved user data representation.
- Updated GetAllUserByFilterQueryHandler to populate new response fields with relevant data.
- Modified Protobuf definitions to include new fields in GetAllUserByFilterResponseModel.
- Bumped Protobuf project version to reflect the addition of new features.
2026-08-14 03:18:58 +03:30
masoodafar-web 436e5d7aca Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 9m50s
2026-08-09 02:33:38 +03:30
masoodafar-web 6a04a6a270 feat(package): enhance pagination logic in GetCustomerPackagePurchaseRollupQueryHandler
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m42s
- Updated pagination handling to allow for exporting all results when PageSize is set to 0.
- Adjusted page size and number calculations to accommodate the new export functionality.
- Improved clarity in pagination logic by distinguishing between export and paginated results.
2026-08-09 00:40:25 +03:30
masoodafar-web 427d2cce1d feat(package): update package purchase methods and enhance Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m45s
- Changed package purchase method from DirectPurchase to Manual in multiple command handlers to reflect new business logic.
- Added Manual purchase method to PackagePurchaseMethod enum for clarity.
- Introduced new RPC method GetCustomerPackagePurchaseRollup in Protobuf for summarizing customer package purchases.
- Updated UserPackagePurchaseProfile and UserPackagePurchaseService to support new rollup functionality.
- Bumped Protobuf project version to accommodate new features.
2026-08-08 21:59:14 +03:30
masoodafar-web fb9e3d0109 feat(order): refactor order data mapping for improved clarity and structure
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 13m4s
- Updated GetAllDiscountOrdersQueryHandler to enhance order data mapping by using a more structured approach with AdminOrderDto.
- Refactored user full name construction in GetCustomerOrderQueryHandler and GetCustomerOrdersQueryHandler to utilize UserDisplayName for better readability.
- Added necessary using directives for consistency across query handlers.
2026-08-07 18:39:42 +03:30
masoodafar-web 6c90e4caf0 feat(order): enhance order status management and Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m46s
- Added AdminNotes handling in UpdateOrderStatusCommandHandler to include delivery descriptions.
- Updated DeliveryStatus enum to include ReadyForOfficePickup.
- Enhanced GetCustomerOrderHistoryQueryHandler and UserOrderService to support new delivery status.
- Updated Protobuf definitions for DeliveryStatus to reflect changes.
- Bumped Protobuf project version to accommodate new features.
2026-08-07 17:25:57 +03:30
masoodafar-web 40da1bd8df feat(order): enhance order details with user contact information and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m33s
- Added Phone property to UserAddressDto for capturing the user's mobile number.
- Updated GetOrderByIdQueryHandler to include user mobile in order details.
- Enhanced GetCustomerOrderQueryHandler and GetCustomerOrderResponseDto to include PostalCode and UserMobile.
- Modified userorder.proto to add postal_code and user_mobile fields in GetUserOrderResponse.
- Bumped Protobuf project version to reflect the addition of new features.
2026-08-07 15:48:04 +03:30
masoodafar-web 053c5a7c4f Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 17m27s
2026-08-07 14:38:07 +03:30
masoodafar-web 3810146651 feat(wallet): enforce club membership validation for wallet charge operations
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 19m57s
- Added club membership inclusion in user retrieval for ChargeCreditWallet and VerifyCreditWalletCharge command handlers.
- Implemented validation to ensure users have an active club membership before allowing wallet charges, throwing a BadRequestException if not.
- Updated UserWalletService to handle BadRequestException and return appropriate error messages in responses.
2026-08-06 23:59:36 +03:30
masoodafar-web 82944e11d0 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-07-26 00:52:02 +03:30
masoodafar-web ce98efdd68 feat(sorting): implement ApplyOrderWithInStockPriority method for enhanced product sorting
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Added ApplyOrderWithInStockPriority method to prioritize in-stock products in queries.
- Updated GetDiscountProductsQueryHandler and GetCustomerProductsByFilterQueryHandler to utilize the new sorting method, ensuring in-stock items are listed first followed by user-defined sorting or creation date.
- Improved overall query handling for product availability and sorting logic.
2026-07-26 00:17:44 +03:30
masoodafar-web d232399f43 feat(wallet): add manual credit wallet charge functionality and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced ManualCreditWalletCharge enum value to TransactionType for clarity.
- Added AdminManualCreditCharge and GetManualCreditCharges RPC methods in UserWalletService for handling manual credit charges by admin.
- Created corresponding request and response messages for manual credit charge operations in Protobuf.
- Updated Protobuf project version to reflect the addition of new features.
2026-07-25 23:55:08 +03:30
116 changed files with 13408 additions and 171 deletions
+10 -10
View File
@@ -6,15 +6,15 @@ on:
- kub-stage - kub-stage
env: env:
REGISTRY: 194.5.195.53:30080 REGISTRY: 185.252.31.44:30080
IMAGE_NAME: admin/cms IMAGE_NAME: admin/cms
K8S_SERVER: 194.5.195.53 K8S_SERVER: 185.252.31.44
jobs: jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 194.5.195.53:32082/docker-sshpass:latest image: 185.252.31.44:32082/docker-sshpass:latest
options: --privileged options: --privileged
steps: steps:
- name: Start Docker daemon - name: Start Docker daemon
@@ -22,7 +22,7 @@ jobs:
mkdir -p /etc/docker mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON' cat > /etc/docker/daemon.json << 'DAEMON'
{ {
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"] "insecure-registries": ["185.252.31.44:30080", "185.252.31.44:32500", "185.252.31.44:32082"]
} }
DAEMON DAEMON
echo "🚀 Starting Docker daemon..." echo "🚀 Starting Docker daemon..."
@@ -52,14 +52,14 @@ jobs:
- name: Login to Docker registries - name: Login to Docker registries
run: | run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 185.252.31.44:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Publish Protobuf packages - name: Publish Protobuf packages
run: | run: |
echo "📦 Publishing Protobuf packages..." echo "📦 Publishing Protobuf packages..."
docker run --rm --network host -e CI=true -v $(pwd):/src -w /src \ docker run --rm --network host -e CI=true -v $(pwd):/src -w /src \
194.5.195.53:32082/dotnet/sdk:9.0 sh -c ' 185.252.31.44:32082/dotnet/sdk:9.0 sh -c '
for proj in $(find . -name "*Protobuf*.csproj" -type f); do for proj in $(find . -name "*Protobuf*.csproj" -type f); do
echo "📦 $proj" echo "📦 $proj"
dotnet restore "$proj" --configfile src/NuGet.config dotnet restore "$proj" --configfile src/NuGet.config
@@ -93,11 +93,11 @@ jobs:
# Apply config (Secret) first, then deployment # Apply config (Secret) first, then deployment
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} " sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl apply -f /tmp/cms-config.yaml && kubectl apply -n kbs-stage -f /tmp/cms-config.yaml &&
kubectl apply -f /tmp/cms-deployment.yaml && kubectl apply -n kbs-stage -f /tmp/cms-deployment.yaml &&
crictl rmi ${REGISTRY}/${IMAGE_NAME}:latest 2>/dev/null || true && crictl rmi ${REGISTRY}/${IMAGE_NAME}:latest 2>/dev/null || true &&
kubectl rollout restart deployment/cms && kubectl -n kbs-stage rollout restart deployment/cms &&
kubectl rollout status deployment/cms --timeout=180s && kubectl -n kbs-stage rollout status deployment/cms --timeout=180s &&
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
" "
echo "✅ Deployed!" echo "✅ Deployed!"
+6 -6
View File
@@ -6,7 +6,7 @@ on:
- production - production
env: env:
REGISTRY: 194.5.195.53:30080 REGISTRY: 185.252.31.44:30080
IMAGE_NAME: admin/cms IMAGE_NAME: admin/cms
K8S_SERVER: 45.149.79.127 K8S_SERVER: 45.149.79.127
@@ -14,7 +14,7 @@ jobs:
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: 194.5.195.53:32082/docker-sshpass:latest image: 185.252.31.44:32082/docker-sshpass:latest
options: --privileged options: --privileged
steps: steps:
- name: Start Docker daemon - name: Start Docker daemon
@@ -22,7 +22,7 @@ jobs:
mkdir -p /etc/docker mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON' cat > /etc/docker/daemon.json << 'DAEMON'
{ {
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"] "insecure-registries": ["185.252.31.44:30080", "185.252.31.44:32500", "185.252.31.44:32082"]
} }
DAEMON DAEMON
echo "🚀 Starting Docker daemon..." echo "🚀 Starting Docker daemon..."
@@ -50,14 +50,14 @@ jobs:
- name: Login to Docker registries - name: Login to Docker registries
run: | run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 185.252.31.44:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Publish Protobuf packages - name: Publish Protobuf packages
run: | run: |
echo "📦 Publishing Protobuf packages..." echo "📦 Publishing Protobuf packages..."
docker run --rm --network host -v $(pwd):/src -w /src \ docker run --rm --network host -v $(pwd):/src -w /src \
194.5.195.53:32082/dotnet/sdk:9.0 sh -c ' 185.252.31.44:32082/dotnet/sdk:9.0 sh -c '
for proj in $(find . -name "*Protobuf*.csproj" -type f); do for proj in $(find . -name "*Protobuf*.csproj" -type f); do
echo "📦 $proj" echo "📦 $proj"
dotnet restore "$proj" --configfile src/NuGet.config dotnet restore "$proj" --configfile src/NuGet.config
@@ -65,7 +65,7 @@ jobs:
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg" dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
[ -f "$nupkg" ] && dotnet nuget push "$nupkg" \ [ -f "$nupkg" ] && dotnet nuget push "$nupkg" \
--source "http://194.5.195.53:32081/repository/foursat-nuget-hosted/index.json" \ --source "http://185.252.31.44:32081/repository/foursat-nuget-hosted/index.json" \
--api-key "admin:87zH26nbqT" \ --api-key "admin:87zH26nbqT" \
--skip-duplicate --allow-insecure-connections || true --skip-duplicate --allow-insecure-connections || true
done done
+3 -3
View File
@@ -1,4 +1,4 @@
FROM 194.5.195.53:32082/dotnet/sdk:9.0 AS build FROM 185.252.31.44:32082/dotnet/sdk:9.0 AS build
WORKDIR /src WORKDIR /src
# Copy NuGet config first # Copy NuGet config first
@@ -7,11 +7,11 @@ COPY src/NuGet.config ./
# Copy solution and project files # Copy solution and project files
COPY src/ ./ COPY src/ ./
# Restore with Nexus config and publish # Restore with Nexus config and publish
RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" --configfile NuGet.config RUN dotnet restore "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" --configfile NuGet.config
RUN dotnet publish "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" -c Release -o /app/publish --no-restore RUN dotnet publish "CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj" -c Release -o /app/publish --no-restore
FROM 194.5.195.53:32082/dotnet/aspnet:9.0 AS runtime FROM 185.252.31.44:32082/dotnet/aspnet:9.0 AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
+2 -2
View File
@@ -1,4 +1,4 @@
# CMS Microservice # CMS Microservice
Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md). Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md).
+18
View File
@@ -24,6 +24,24 @@ stringData:
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8", "MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": false "UseSandbox": false
}, },
"TwentyCard": {
"BaseUrl": "https://gateway.20card.ir/ipg",
"TerminalId": "165674",
"TerminalUsername": "ea5y1biehi",
"TerminalPassword": "SaLamAT@1404!",
"CreditPortalUrl": "https://userpanel.20card.ir/",
"DefaultEmail": "info@kbs2.ir",
"Packages": {
"4": {
"GatewayAmountToman": 11200000,
"WalletCreditToman": 12656000
},
"1": {
"GatewayAmountToman": 11200000,
"WalletCreditToman": 12656000
}
}
},
"CmsBaseUrl": "https://cms.kbs2.ir", "CmsBaseUrl": "https://cms.kbs2.ir",
"FrontOfficeBaseUrl": "https://kbs2.ir", "FrontOfficeBaseUrl": "https://kbs2.ir",
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=", "JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
+1 -1
View File
@@ -35,7 +35,7 @@ spec:
spec: spec:
containers: containers:
- name: cms - name: cms
image: 194.5.195.53:30080/admin/cms:prod image: 185.252.31.44:30080/admin/cms:prod
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8080 - containerPort: 8080
+21 -2
View File
@@ -11,7 +11,7 @@ apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
name: cms-appsettings name: cms-appsettings
namespace: default namespace: kbs-stage
labels: labels:
app: cms app: cms
environment: staging environment: staging
@@ -24,6 +24,25 @@ stringData:
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8", "MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": true "UseSandbox": true
}, },
"TwentyCard": {
"BaseUrl": "https://gateway.20card.ir/ipg",
"TerminalId": "165674",
"TerminalUsername": "ea5y1biehi",
"TerminalPassword": "SaLamAT@1404!",
"CreditPortalUrl": "https://userpanel.20card.ir/",
"CallbackBaseUrl": "https://kbs2.ir",
"DefaultEmail": "info@kbs2.ir",
"Packages": {
"4": {
"GatewayAmountToman": 11200000,
"WalletCreditToman": 12656000
},
"1": {
"GatewayAmountToman": 11200000,
"WalletCreditToman": 12656000
}
}
},
"CmsBaseUrl": "https://cms.se.kbs1.ir", "CmsBaseUrl": "https://cms.se.kbs1.ir",
"FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir", "FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir",
"FMS": { "FMS": {
@@ -34,7 +53,7 @@ stringData:
"JwtAudience": "https://localhost", "JwtAudience": "https://localhost",
"JwtExpiryInDays": 5, "JwtExpiryInDays": 5,
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Data Source=194.5.195.53,31433; Initial Catalog=Foursat;User ID=sa;Password=87zH26nbqT;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False", "DefaultConnection": "Data Source=mssql-svc.platform.svc.cluster.local,1433; Initial Catalog=Foursat;User ID=sa;Password=87zH26nbqT;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"providerName": "System.Data.SqlClient" "providerName": "System.Data.SqlClient"
}, },
"Otp": { "Otp": {
+11 -6
View File
@@ -4,12 +4,14 @@ apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
name: cms-uploads-pvc name: cms-uploads-pvc
namespace: default namespace: kbs-stage
labels: labels:
app: cms app: cms
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
storageClassName: migrated-local
volumeName: pv-cms-uploads-migrated
resources: resources:
requests: requests:
storage: 20Gi storage: 20Gi
@@ -20,7 +22,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: cms name: cms
namespace: default namespace: kbs-stage
labels: labels:
app: cms app: cms
spec: spec:
@@ -33,10 +35,12 @@ spec:
labels: labels:
app: cms app: cms
spec: spec:
imagePullSecrets:
- name: gitea-registry-secret
containers: containers:
- name: cms - name: cms
image: 194.5.195.53:30080/admin/cms:latest image: 185.252.31.44:30080/admin/cms:latest
imagePullPolicy: Always imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 8080 - containerPort: 8080
name: http name: http
@@ -89,7 +93,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: cms-svc name: cms-svc
namespace: default namespace: kbs-stage
spec: spec:
selector: selector:
app: cms app: cms
@@ -107,7 +111,7 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: cms-ingress name: cms-ingress
namespace: default namespace: kbs-stage
annotations: annotations:
kubernetes.io/ingress.class: "nginx" kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt-prod" cert-manager.io/cluster-issuer: "letsencrypt-prod"
@@ -115,6 +119,7 @@ metadata:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec: spec:
ingressClassName: nginx
tls: tls:
- hosts: - hosts:
- cms.se.kbs1.ir - cms.se.kbs1.ir
+340
View File
@@ -0,0 +1,340 @@
/*
Cleanup all side-effects of a test FrontOffice user (register + package + club + wallet + invitations).
Usage:
EXEC dbo.usp_CleanupTestUser @Mobile = N'09037437157', @DryRun = 1;
EXEC dbo.usp_CleanupTestUser @Mobile = N'09037437157';
EXEC dbo.usp_CleanupTestUser @Mobile = N'09...', @ReparentChildren = 1;
EXEC dbo.usp_CleanupTestUser @Mobile = N'09...', @ReversePool = 1, @AbortIfPoolCalculated = 1;
Pool reverse: for each ClubMembershipCycle, subtract Packages.ActivationFee
(fallback: ClubMembership.InitialContribution) from CMS.WeeklyCommissionPools
for matching WeekDefinition × PackageId.
Does NOT delete pool rows (shared). Does NOT claw back commission already paid to OTHER users.
Does NOT touch: Packages master, Contracts master, WeekDefinitions, Roles, catalogs.
*/
CREATE OR ALTER PROCEDURE dbo.usp_CleanupTestUser
@UserId BIGINT = NULL,
@Mobile NVARCHAR(20) = NULL,
@HardDeleteUser BIT = 1,
@ReparentChildren BIT = 0,
@ReversePool BIT = 1,
@AbortIfPoolCalculated BIT = 0,
@DryRun BIT = 0
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
IF @UserId IS NULL AND (@Mobile IS NULL OR LTRIM(RTRIM(@Mobile)) = N'')
BEGIN
RAISERROR(N'Provide @UserId and/or @Mobile.', 16, 1);
RETURN;
END;
DECLARE @MobileNorm NVARCHAR(20) = NULL;
IF @Mobile IS NOT NULL
BEGIN
SET @MobileNorm = LTRIM(RTRIM(@Mobile));
IF LEFT(@MobileNorm, 3) = N'+98' SET @MobileNorm = N'0' + SUBSTRING(@MobileNorm, 4, 20);
IF LEFT(@MobileNorm, 2) = N'98' AND LEN(@MobileNorm) = 12 SET @MobileNorm = N'0' + SUBSTRING(@MobileNorm, 3, 20);
IF LEFT(@MobileNorm, 1) = N'9' AND LEN(@MobileNorm) = 10 SET @MobileNorm = N'0' + @MobileNorm;
END;
IF @UserId IS NULL
BEGIN
SELECT TOP (1) @UserId = Id
FROM CMS.Users
WHERE Mobile = @MobileNorm
OR Mobile = @Mobile
OR REPLACE(Mobile, N'+98', N'0') = @MobileNorm
ORDER BY IsDeleted ASC, Id DESC;
END;
IF @UserId IS NULL
BEGIN
RAISERROR(N'User not found for given @UserId/@Mobile.', 16, 1);
RETURN;
END;
DECLARE @UserMobile NVARCHAR(20);
SELECT @UserMobile = Mobile FROM CMS.Users WHERE Id = @UserId;
IF @UserMobile IS NULL
BEGIN
RAISERROR(N'UserId %I64d not found.', 16, 1, @UserId);
RETURN;
END;
DECLARE @ChildCount INT =
(SELECT COUNT(*) FROM CMS.Users WHERE NetworkParentId = @UserId AND IsDeleted = 0);
IF @ChildCount > 0 AND @ReparentChildren = 0 AND @HardDeleteUser = 1
BEGIN
RAISERROR(N'User has %d active network children. Pass @ReparentChildren=1 or soft-delete only.', 16, 1, @ChildCount);
RETURN;
END;
PRINT N'=== usp_CleanupTestUser ===';
PRINT N'UserId=' + CAST(@UserId AS NVARCHAR(20)) + N' Mobile=' + ISNULL(@UserMobile, N'?')
+ N' DryRun=' + CAST(@DryRun AS NVARCHAR(1))
+ N' ReversePool=' + CAST(@ReversePool AS NVARCHAR(1));
DECLARE @Wallets TABLE (WalletId BIGINT PRIMARY KEY);
INSERT INTO @Wallets (WalletId)
SELECT Id FROM CMS.UserWallets WHERE UserId = @UserId;
DECLARE @Tx TABLE (TransactionId BIGINT PRIMARY KEY);
INSERT INTO @Tx (TransactionId)
SELECT DISTINCT x.TransactionId
FROM (
SELECT TransactionId FROM CMS.UserPackagePurchases WHERE UserId = @UserId AND TransactionId IS NOT NULL
UNION
SELECT TransactionId FROM CMS.PaymentTransactions WHERE UserId = @UserId AND TransactionId IS NOT NULL
UNION
SELECT TransactionId FROM CMS.UserOrders WHERE UserId = @UserId AND TransactionId IS NOT NULL
UNION
SELECT TransactionId FROM CMS.ManualPayments WHERE UserId = @UserId AND TransactionId IS NOT NULL
UNION
SELECT TransactionId FROM CMS.DayaLoanContracts WHERE UserId = @UserId AND TransactionId IS NOT NULL
UNION
SELECT TransactionId FROM CMS.DiscountOrders WHERE UserId = @UserId AND TransactionId IS NOT NULL
UNION
SELECT h.RefrenceId
FROM CMS.UserWalletHistories h
INNER JOIN @Wallets w ON w.WalletId = h.WalletId
WHERE h.RefrenceId IS NOT NULL
) x
WHERE x.TransactionId IS NOT NULL;
DECLARE @Orders TABLE (OrderId BIGINT PRIMARY KEY);
INSERT INTO @Orders (OrderId)
SELECT Id FROM CMS.UserOrders WHERE UserId = @UserId;
DECLARE @DiscountOrders TABLE (DiscountOrderId BIGINT PRIMARY KEY);
INSERT INTO @DiscountOrders (DiscountOrderId)
SELECT Id FROM CMS.DiscountOrders WHERE UserId = @UserId;
DECLARE @ClubMemberships TABLE (ClubMembershipId BIGINT PRIMARY KEY);
INSERT INTO @ClubMemberships (ClubMembershipId)
SELECT Id FROM CMS.ClubMemberships WHERE UserId = @UserId;
DECLARE @Cycles TABLE (CycleId BIGINT PRIMARY KEY);
INSERT INTO @Cycles (CycleId)
SELECT Id FROM CMS.ClubMembershipCycles WHERE UserId = @UserId
UNION
SELECT Id FROM CMS.ClubMembershipCycles WHERE ClubMembershipId IN (SELECT ClubMembershipId FROM @ClubMemberships);
DECLARE @Payouts TABLE (PayoutId BIGINT PRIMARY KEY);
INSERT INTO @Payouts (PayoutId)
SELECT Id FROM CMS.UserCommissionPayouts WHERE UserId = @UserId;
DECLARE @PoolReversals TABLE (
CycleId BIGINT,
PackageId BIGINT,
WeekDefinitionId BIGINT NULL,
PoolId BIGINT NULL,
Fee BIGINT,
PoolAmountBefore BIGINT NULL,
IsCalculated BIT NULL,
Note NVARCHAR(200)
);
INSERT INTO @PoolReversals (CycleId, PackageId, WeekDefinitionId, PoolId, Fee, PoolAmountBefore, IsCalculated, Note)
SELECT
c.Id,
c.PackageId,
wd.Id,
wp.Id,
ISNULL(NULLIF(cm.InitialContribution, 0), ISNULL(p.ActivationFee, 0)),
wp.TotalPoolAmount,
wp.IsCalculated,
CASE
WHEN wd.Id IS NULL THEN N'NO_WEEK_MATCH'
WHEN wp.Id IS NULL THEN N'NO_POOL_ROW'
WHEN wp.IsCalculated = 1 THEN N'POOL_ALREADY_CALCULATED'
ELSE N'OK'
END
FROM CMS.ClubMembershipCycles c
LEFT JOIN CMS.ClubMemberships cm ON cm.Id = c.ClubMembershipId
LEFT JOIN CMS.Packages p ON p.Id = c.PackageId
LEFT JOIN CMS.WeekDefinitions wd
ON CAST(c.PackagePurchasedAt AS DATE) >= CAST(wd.StartDate AS DATE)
AND CAST(c.PackagePurchasedAt AS DATE) <= CAST(wd.EndDate AS DATE)
AND wd.IsDeleted = 0
LEFT JOIN CMS.WeeklyCommissionPools wp
ON wp.WeekDefinitionId = wd.Id
AND wp.PackageId = c.PackageId
AND wp.IsDeleted = 0
WHERE c.Id IN (SELECT CycleId FROM @Cycles);
SELECT N'Users' AS Entity, COUNT(*) AS Cnt FROM CMS.Users WHERE Id = @UserId
UNION ALL SELECT N'OtpTokens', COUNT(*) FROM CMS.OtpTokens WHERE Mobile IN (@UserMobile, @MobileNorm, @Mobile)
UNION ALL SELECT N'UserInvitations', COUNT(*) FROM CMS.UserInvitations WHERE InviterUserId = @UserId OR InviteeUserId = @UserId
UNION ALL SELECT N'ClubMemberships', COUNT(*) FROM @ClubMemberships
UNION ALL SELECT N'ClubMembershipCycles', COUNT(*) FROM @Cycles
UNION ALL SELECT N'UserPackagePurchases', COUNT(*) FROM CMS.UserPackagePurchases WHERE UserId = @UserId
UNION ALL SELECT N'PaymentTransactions', COUNT(*) FROM CMS.PaymentTransactions WHERE UserId = @UserId OR Mobile = @UserMobile
UNION ALL SELECT N'UserWallets', COUNT(*) FROM @Wallets
UNION ALL SELECT N'UserWalletHistories', COUNT(*) FROM CMS.UserWalletHistories WHERE WalletId IN (SELECT WalletId FROM @Wallets)
UNION ALL SELECT N'Transactions(collected)', COUNT(*) FROM @Tx
UNION ALL SELECT N'NetworkChildren', @ChildCount
UNION ALL SELECT N'UserOrders', COUNT(*) FROM @Orders
UNION ALL SELECT N'DiscountOrders', COUNT(*) FROM @DiscountOrders
UNION ALL SELECT N'UserCommissionPayouts', COUNT(*) FROM @Payouts
UNION ALL SELECT N'PoolReversals(planned)', COUNT(*) FROM @PoolReversals WHERE Fee > 0;
SELECT N'--- Pool reverse plan ---' AS Info;
SELECT * FROM @PoolReversals;
IF @ReversePool = 1 AND @AbortIfPoolCalculated = 1
AND EXISTS (SELECT 1 FROM @PoolReversals WHERE IsCalculated = 1 AND Fee > 0)
BEGIN
RAISERROR(N'One or more target weekly pools are already calculated. Aborting (@AbortIfPoolCalculated=1).', 16, 1);
RETURN;
END;
IF @DryRun = 1
BEGIN
PRINT N'DryRun=1 — no changes committed.';
RETURN;
END;
BEGIN TRANSACTION;
BEGIN TRY
/* ---- Reverse weekly pool charges FIRST (needs cycle + package data) ---- */
IF @ReversePool = 1
BEGIN
UPDATE wp
SET wp.TotalPoolAmount = CASE
WHEN wp.TotalPoolAmount >= r.Fee THEN wp.TotalPoolAmount - r.Fee
ELSE 0
END,
wp.LastModified = SYSUTCDATETIME()
FROM CMS.WeeklyCommissionPools wp
INNER JOIN (
SELECT PoolId, SUM(Fee) AS Fee
FROM @PoolReversals
WHERE PoolId IS NOT NULL AND Fee > 0
GROUP BY PoolId
) r ON r.PoolId = wp.Id;
PRINT N'Pool reverse applied for ' + CAST(@@ROWCOUNT AS NVARCHAR(20)) + N' pool row(s).';
END;
/* ---- Club ---- */
DELETE h FROM CMS.ClubMembershipCycleHistories h
WHERE h.UserId = @UserId OR h.ClubMembershipCycleId IN (SELECT CycleId FROM @Cycles);
DELETE FROM CMS.ClubMembershipCycles
WHERE Id IN (SELECT CycleId FROM @Cycles) OR UserId = @UserId;
DELETE FROM CMS.ClubMembershipHistories WHERE UserId = @UserId
OR ClubMembershipId IN (SELECT ClubMembershipId FROM @ClubMemberships);
DELETE FROM CMS.UserClubFeatures WHERE UserId = @UserId
OR ClubMembershipId IN (SELECT ClubMembershipId FROM @ClubMemberships);
DELETE FROM CMS.ClubMemberships WHERE UserId = @UserId;
/* ---- Commission payouts of THIS user only ---- */
DELETE h FROM CMS.CommissionPayoutHistories h
WHERE h.UserId = @UserId OR h.UserCommissionPayoutId IN (SELECT PayoutId FROM @Payouts);
DELETE FROM CMS.UserCommissionPayouts WHERE UserId = @UserId;
/* ---- Network ---- */
DELETE FROM CMS.NetworkWeeklyBalances WHERE UserId = @UserId;
DELETE FROM CMS.NetworkMembershipHistories
WHERE UserId = @UserId OR OldParentId = @UserId OR NewParentId = @UserId;
IF @ChildCount > 0 AND @ReparentChildren = 1
UPDATE CMS.Users SET NetworkParentId = NULL, LastModified = SYSUTCDATETIME()
WHERE NetworkParentId = @UserId;
/* ---- Invitations ---- */
DELETE FROM CMS.UserInvitations
WHERE InviterUserId = @UserId OR InviteeUserId = @UserId;
/* ---- Package / payment ---- */
DELETE FROM CMS.UserPackagePurchases WHERE UserId = @UserId;
DELETE FROM CMS.PaymentTransactions
WHERE UserId = @UserId OR Mobile = @UserMobile OR Mobile = @MobileNorm;
/* ---- Manual / Daya ---- */
DELETE FROM CMS.ManualPayments WHERE UserId = @UserId;
DELETE FROM CMS.DayaLoanContracts WHERE UserId = @UserId;
/* ---- Discount shop ---- */
DELETE d FROM CMS.DiscountOrderDetails d
WHERE d.DiscountOrderId IN (SELECT DiscountOrderId FROM @DiscountOrders);
DELETE FROM CMS.DiscountOrders WHERE UserId = @UserId;
DELETE FROM CMS.DiscountShoppingCarts WHERE UserId = @UserId;
/* ---- Stock ---- */
DELETE FROM CMS.StockMovements
WHERE PerformedByUserId = @UserId
OR OrderId IN (SELECT OrderId FROM @Orders)
OR DiscountOrderId IN (SELECT DiscountOrderId FROM @DiscountOrders);
/* ---- Orders ---- */
DELETE FROM CMS.FactorDetails WHERE OrderId IN (SELECT OrderId FROM @Orders);
DELETE FROM CMS.OrderVATs WHERE OrderId IN (SELECT OrderId FROM @Orders);
DELETE FROM CMS.UserOrders WHERE UserId = @UserId;
/* ---- Wallet ---- */
DELETE FROM CMS.UserWalletHistories WHERE WalletId IN (SELECT WalletId FROM @Wallets);
DELETE FROM CMS.UserWallets WHERE UserId = @UserId;
/* ---- Cart / address / roles / contracts ---- */
DELETE FROM CMS.UserCarts WHERE UserId = @UserId;
DELETE FROM CMS.UserAddresses WHERE UserId = @UserId;
DELETE FROM CMS.UserRoles WHERE UserId = @UserId;
DELETE FROM CMS.UserContracts WHERE UserId = @UserId;
/* ---- Content authored by test user ---- */
UPDATE CMS.BlogPosts SET IsDeleted = 1, LastModified = SYSUTCDATETIME()
WHERE AuthorUserId = @UserId AND IsDeleted = 0;
UPDATE CMS.PublicMessages SET IsDeleted = 1, LastModified = SYSUTCDATETIME()
WHERE CreatedByUserId = @UserId AND IsDeleted = 0;
/* ---- Transactions ---- */
DELETE t
FROM CMS.Transactions t
WHERE t.Id IN (SELECT TransactionId FROM @Tx)
AND NOT EXISTS (SELECT 1 FROM CMS.UserPackagePurchases p WHERE p.TransactionId = t.Id)
AND NOT EXISTS (SELECT 1 FROM CMS.PaymentTransactions p WHERE p.TransactionId = t.Id)
AND NOT EXISTS (SELECT 1 FROM CMS.UserOrders o WHERE o.TransactionId = t.Id)
AND NOT EXISTS (SELECT 1 FROM CMS.ManualPayments m WHERE m.TransactionId = t.Id)
AND NOT EXISTS (SELECT 1 FROM CMS.DayaLoanContracts d WHERE d.TransactionId = t.Id)
AND NOT EXISTS (SELECT 1 FROM CMS.DiscountOrders d WHERE d.TransactionId = t.Id);
/* ---- OTP ---- */
DELETE FROM CMS.OtpTokens
WHERE Mobile IN (@UserMobile, @MobileNorm, ISNULL(@Mobile, N''));
/* ---- User ---- */
IF @HardDeleteUser = 1
DELETE FROM CMS.Users WHERE Id = @UserId;
ELSE
UPDATE CMS.Users
SET IsDeleted = 1, LastModified = SYSUTCDATETIME()
WHERE Id = @UserId;
COMMIT TRANSACTION;
PRINT N'Cleanup completed successfully.';
SELECT N'DONE' AS Status, @UserId AS UserId, @UserMobile AS Mobile,
@HardDeleteUser AS HardDeleted, @ReversePool AS PoolReversed;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
DECLARE @Err NVARCHAR(4000) = ERROR_MESSAGE();
RAISERROR(N'Cleanup failed: %s', 16, 1, @Err);
END CATCH
END;
GO
@@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" /> <PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.10" />
<PackageReference Include="DateTimeConverterCL" Version="1.0.0" /> <PackageReference Include="DateTimeConverterCL" Version="1.0.0" />
@@ -163,10 +163,10 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
request.UserId request.UserId
); );
// در فعال‌سازی اجباری، اگر PackagePurchaseMethod تنظیم نشده، مقدار DirectPurchase بگذار // در فعال‌سازی اجباری ادمین، اگر روش خرید تنظیم نشده، دستی در نظر بگیر
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None) if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{ {
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase; user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
_context.Users.Update(user); _context.Users.Update(user);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
@@ -1,3 +1,5 @@
using CMSMicroservice.Application.Common;
namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal; namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawalCommand, Unit> public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawalCommand, Unit>
@@ -37,7 +39,10 @@ public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawal
if (request.Method == WithdrawalMethod.Cash) if (request.Method == WithdrawalMethod.Cash)
{ {
payout.IbanNumber = request.IbanNumber; var normalizedIban = IbanNormalizer.TryNormalize(request.IbanNumber);
if (normalizedIban is null)
throw new InvalidOperationException("فرمت شماره شبا معتبر نیست. باید IR و ۲۴ رقم باشد.");
payout.IbanNumber = normalizedIban;
} }
_context.UserCommissionPayouts.Update(payout); _context.UserCommissionPayouts.Update(payout);
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using System.Linq.Dynamic.Core; using System.Linq.Dynamic.Core;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
@@ -16,6 +17,21 @@ public static class SortByExtensions
return source.OrderBy(NormalizeSortBy(sortBy)); return source.OrderBy(NormalizeSortBy(sortBy));
} }
/// <summary>
/// موجودی‌دارها اول، سپس سورت درخواستی کاربر (یا Created desc اگر SortBy خالی باشد).
/// </summary>
public static IQueryable<TSource> ApplyOrderWithInStockPriority<TSource>(
this IQueryable<TSource> source,
string? sortBy,
Expression<Func<TSource, bool>> isInStock) where TSource : BaseAuditableEntity
{
var ordered = source.OrderByDescending(isInStock);
if (string.IsNullOrWhiteSpace(sortBy))
return ordered.ThenByDescending(p => p.Created);
return ordered.ThenBy(NormalizeSortBy(sortBy));
}
/// <summary> /// <summary>
/// Dynamic LINQ treats "-Created" as unary minus on DateTime (invalid). /// Dynamic LINQ treats "-Created" as unary minus on DateTime (invalid).
/// Project convention: leading '-' means descending sort. /// Project convention: leading '-' means descending sort.
@@ -0,0 +1,34 @@
using System.Text.RegularExpressions;
namespace CMSMicroservice.Application.Common;
/// <summary>
/// نرمال‌سازی و اعتبارسنجی شماره شبا ایران (IR + 24 رقم).
/// </summary>
public static class IbanNormalizer
{
private static readonly Regex IranianIbanRegex = new(@"^IR\d{24}$", RegexOptions.Compiled);
/// <summary>
/// فاصله و خط تیره را حذف می‌کند، به حروف بزرگ تبدیل می‌کند و یک پیشوند IR می‌گذارد.
/// در صورت نامعتبر بودن، null برمی‌گرداند.
/// </summary>
public static string? TryNormalize(string? iban)
{
if (string.IsNullOrWhiteSpace(iban))
return null;
var normalized = iban.Trim().ToUpperInvariant()
.Replace(" ", "", StringComparison.Ordinal)
.Replace("-", "", StringComparison.Ordinal);
if (normalized.StartsWith("IR", StringComparison.Ordinal))
normalized = normalized[2..];
normalized = "IR" + normalized;
return IranianIbanRegex.IsMatch(normalized) ? normalized : null;
}
public static bool IsValid(string? iban) => TryNormalize(iban) is not null;
}
@@ -21,6 +21,7 @@ public interface IApplicationDbContext
DbSet<Product> Products { get; } DbSet<Product> Products { get; }
DbSet<ProductImage> ProductImages { get; } DbSet<ProductImage> ProductImages { get; }
DbSet<User> Users { get; } DbSet<User> Users { get; }
DbSet<UserInvitation> UserInvitations { get; }
DbSet<OtpToken> OtpTokens { get; } DbSet<OtpToken> OtpTokens { get; }
DbSet<Contract> Contracts { get; } DbSet<Contract> Contracts { get; }
DbSet<UserContract> UserContracts { get; } DbSet<UserContract> UserContracts { get; }
@@ -0,0 +1,9 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Resolve payment gateway by provider key (zarinpal, 20card, …).
/// </summary>
public interface IPaymentGatewayResolver
{
IPaymentGatewayService Get(string? provider);
}
@@ -98,6 +98,26 @@ public class PaymentRequest
/// URL بازگشت بعد از پرداخت /// URL بازگشت بعد از پرداخت
/// </summary> /// </summary>
public string CallbackUrl { get; set; } = string.Empty; public string CallbackUrl { get; set; } = string.Empty;
/// <summary>
/// نام پرداخت‌کننده (برای درگاه‌هایی مثل ۲۰کارت)
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// شماره فاکتور / سفارش
/// </summary>
public string Invoice { get; set; } = string.Empty;
/// <summary>
/// ایمیل (اختیاری — برای ۲۰کارت)
/// </summary>
public string? Email { get; set; }
/// <summary>
/// کد ملی (اختیاری — برای ۲۰کارت)
/// </summary>
public string? NationalId { get; set; }
} }
/// <summary> /// <summary>
@@ -0,0 +1,29 @@
namespace CMSMicroservice.Application.Common;
/// <summary>
/// نام نمایشی کاربر برای گزارش‌ها؛ اگر نام خالی باشد موبایل یا شناسه.
/// </summary>
public static class UserDisplayName
{
public static string From(string? firstName, string? lastName, string? mobile, long userId)
{
var name = string.Join(" ",
new[] { firstName, lastName }.Where(s => !string.IsNullOrWhiteSpace(s)));
if (!string.IsNullOrWhiteSpace(name))
return name;
if (!string.IsNullOrWhiteSpace(mobile))
return mobile.Trim();
return userId > 0 ? $"کاربر {userId}" : "—";
}
public static string From(Domain.Entities.User? user)
{
if (user is null)
return string.Empty;
return From(user.FirstName, user.LastName, user.Mobile, user.Id);
}
}
@@ -35,6 +35,11 @@ public class UpdateOrderStatusCommandHandler : IRequestHandler<UpdateOrderStatus
order.TrackingCode = request.TrackingCode; order.TrackingCode = request.TrackingCode;
} }
if (!string.IsNullOrEmpty(request.AdminNotes))
{
order.DeliveryDescription = request.AdminNotes;
}
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return new UpdateOrderStatusResponseDto return new UpdateOrderStatusResponseDto
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models; using CMSMicroservice.Application.Common.Models;
using MediatR; using MediatR;
@@ -80,35 +81,58 @@ public class GetAllDiscountOrdersQueryHandler : IRequestHandler<GetAllDiscountOr
// Apply pagination // Apply pagination
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 20 }; var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 20 };
var orders = await query var rows = await query
.OrderByDescending(o => o.Created) .OrderByDescending(o => o.Created)
.Skip((pagination.PageNumber - 1) * pagination.PageSize) .Skip((pagination.PageNumber - 1) * pagination.PageSize)
.Take(pagination.PageSize) .Take(pagination.PageSize)
.Select(o => new AdminOrderDto .Select(o => new
{ {
Id = o.Id, o.Id,
UserId = o.UserId, o.UserId,
UserFullName = (o.User.FirstName ?? "") + " " + (o.User.LastName ?? ""), FirstName = o.User.FirstName,
UserMobile = o.User.Mobile, LastName = o.User.LastName,
TotalAmount = o.TotalAmount, Mobile = o.User.Mobile,
DiscountBalanceUsed = o.DiscountBalanceUsed, o.TotalAmount,
GatewayAmountPaid = o.GatewayAmountPaid, o.DiscountBalanceUsed,
VatAmount = o.VatAmount, o.GatewayAmountPaid,
PaymentStatus = o.PaymentStatus, o.VatAmount,
PaymentDate = o.PaymentDate, o.PaymentStatus,
DeliveryStatus = o.DeliveryStatus, o.PaymentDate,
DeliveryDate = null, // TODO: Add DeliveryDate to DiscountOrder if needed o.DeliveryStatus,
ShippingAddress = o.UserAddress.Address, ShippingAddress = o.UserAddress.Address,
ReceiverName = o.UserAddress.Title, ReceiverName = o.UserAddress.Title,
ReceiverMobile = o.User.Mobile, o.TrackingCode,
TrackingCode = o.TrackingCode,
AdminNote = o.DeliveryDescription, AdminNote = o.DeliveryDescription,
Created = o.Created, o.Created,
LastModified = o.LastModified, o.LastModified,
ItemsCount = o.OrderDetails.Count ItemsCount = o.OrderDetails.Count
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var orders = rows.Select(o => new AdminOrderDto
{
Id = o.Id,
UserId = o.UserId,
UserFullName = UserDisplayName.From(o.FirstName, o.LastName, o.Mobile, o.UserId),
UserMobile = o.Mobile,
TotalAmount = o.TotalAmount,
DiscountBalanceUsed = o.DiscountBalanceUsed,
GatewayAmountPaid = o.GatewayAmountPaid,
VatAmount = o.VatAmount,
PaymentStatus = o.PaymentStatus,
PaymentDate = o.PaymentDate,
DeliveryStatus = o.DeliveryStatus,
DeliveryDate = null,
ShippingAddress = o.ShippingAddress,
ReceiverName = o.ReceiverName,
ReceiverMobile = o.Mobile,
TrackingCode = o.TrackingCode,
AdminNote = o.AdminNote,
Created = o.Created,
LastModified = o.LastModified,
ItemsCount = o.ItemsCount
}).ToList();
return new GetAllDiscountOrdersResponseDto return new GetAllDiscountOrdersResponseDto
{ {
MetaData = new MetaData MetaData = new MetaData
@@ -57,10 +57,8 @@ public class GetDiscountProductsQueryHandler : IRequestHandler<GetDiscountProduc
query = query.Where(p => p.Price <= request.MaxPrice.Value); query = query.Where(p => p.Price <= request.MaxPrice.Value);
} }
if (!string.IsNullOrEmpty(request.SortBy)) // موجودی‌دارها اول، سپس سورت درخواستی
query = query.ApplyOrder(request.SortBy); query = query.ApplyOrderWithInStockPriority(request.SortBy, p => p.RemainingCount > 0);
else
query = query.OrderByDescending(p => p.Created);
var totalCount = await query.CountAsync(cancellationToken); var totalCount = await query.CountAsync(cancellationToken);
@@ -32,6 +32,8 @@ public class UserAddressDto
public string Title { get; set; } public string Title { get; set; }
public string Address { get; set; } public string Address { get; set; }
public string PostalCode { get; set; } public string PostalCode { get; set; }
/// <summary>شماره تماس سفارش‌دهنده (موبایل کاربر) برای برچسب پست</summary>
public string? Phone { get; set; }
} }
public class OrderItemDto public class OrderItemDto
@@ -25,6 +25,7 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
var order = await query var order = await query
.Include(o => o.UserAddress) .Include(o => o.UserAddress)
.Include(o => o.User)
.Include(o => o.OrderDetails) .Include(o => o.OrderDetails)
.ThenInclude(od => od.Product) .ThenInclude(od => od.Product)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
@@ -50,7 +51,8 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
{ {
Title = order.UserAddress.Title, Title = order.UserAddress.Title,
Address = order.UserAddress.Address, Address = order.UserAddress.Address,
PostalCode = order.UserAddress.PostalCode PostalCode = order.UserAddress.PostalCode,
Phone = order.User?.Mobile
}, },
Items = order.OrderDetails.Select(od => new OrderItemDto Items = order.OrderDetails.Select(od => new OrderItemDto
{ {
@@ -150,8 +150,18 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
await _context.UserWalletHistories.AddAsync(walletLog, cancellationToken); await _context.UserWalletHistories.AddAsync(walletLog, cancellationToken);
// 9. تنظیم روش خرید پکیج // 9. تنظیم روش خرید پکیج + ثبت ledger خرید دستی
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase; user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
_context.UserPackagePurchases.Add(new UserPackagePurchase
{
UserId = request.UserId,
PackageId = package.Id,
PurchaseMethod = PackagePurchaseMethod.Manual,
PurchasedAt = DateTime.Now,
Amount = balanceAmount,
TransactionId = transaction.Id
});
// 10. ذخیره همه تغییرات // 10. ذخیره همه تغییرات
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
@@ -118,7 +118,7 @@ public class ProcessManualMembershipPaymentCommandHandler : IRequestHandler<Proc
}; };
await _context.UserWalletHistories.AddAsync(balanceLog, cancellationToken); await _context.UserWalletHistories.AddAsync(balanceLog, cancellationToken);
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase; user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
// 10. به‌روزرسانی ManualPayment با TransactionId // 10. به‌روزرسانی ManualPayment با TransactionId
manualPayment.TransactionId = transaction.Id; manualPayment.TransactionId = transaction.Id;
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
@@ -1,4 +1,5 @@
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkStatistics; namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkStatistics;
@@ -34,17 +35,28 @@ public class GetNetworkStatisticsQueryHandler : IRequestHandler<GetNetworkStatis
} }
var allDescendants = GetAllDescendants(userId, allUsers); var allDescendants = GetAllDescendants(userId, allUsers);
// Statistics for the user's network (all descendants)
var totalMembers = allDescendants.Count;
var activeMembers = allDescendants.Count(x => !x.IsDeleted);
// Get direct left and right children // Get direct left and right children
var leftChild = allUsers.FirstOrDefault(x => x.NetworkParentId == userId && x.LegPosition == NetworkLeg.Left); var leftChild = allUsers.FirstOrDefault(x => x.NetworkParentId == userId && x.LegPosition == NetworkLeg.Left);
var rightChild = allUsers.FirstOrDefault(x => x.NetworkParentId == userId && x.LegPosition == NetworkLeg.Right); var rightChild = allUsers.FirstOrDefault(x => x.NetworkParentId == userId && x.LegPosition == NetworkLeg.Right);
// Count all descendants in left and right subtrees var leftLegUserIds = leftChild != null ? GetSubtreeUserIds(leftChild.Id, allUsers) : new HashSet<long>();
var leftLegCount = leftChild != null ? GetAllDescendants(leftChild.Id, allUsers).Count + 1 : 0; // +1 for leftChild itself var rightLegUserIds = rightChild != null ? GetSubtreeUserIds(rightChild.Id, allUsers) : new HashSet<long>();
var rightLegCount = rightChild != null ? GetAllDescendants(rightChild.Id, allUsers).Count + 1 : 0; // +1 for rightChild itself var allLegUserIds = leftLegUserIds.Union(rightLegUserIds).ToList();
// سازمان چپ/راست = فقط اعضای فعال باشگاه در هر پا
var activeClubUserIds = allLegUserIds.Count == 0
? new HashSet<long>()
: (await _context.ClubMemberships
.AsNoTracking()
.Where(cm => cm.IsActive && !cm.IsDeleted && allLegUserIds.Contains(cm.UserId))
.Select(cm => cm.UserId)
.ToListAsync(cancellationToken))
.ToHashSet();
var leftLegCount = leftLegUserIds.Count(id => activeClubUserIds.Contains(id));
var rightLegCount = rightLegUserIds.Count(id => activeClubUserIds.Contains(id));
var totalMembers = leftLegCount + rightLegCount;
var activeMembers = totalMembers;
double leftPercentage = totalMembers > 0 ? (leftLegCount / (double)totalMembers) * 100 : 0; double leftPercentage = totalMembers > 0 ? (leftLegCount / (double)totalMembers) * 100 : 0;
double rightPercentage = totalMembers > 0 ? (rightLegCount / (double)totalMembers) * 100 : 0; double rightPercentage = totalMembers > 0 ? (rightLegCount / (double)totalMembers) * 100 : 0;
@@ -124,6 +136,9 @@ public class GetNetworkStatisticsQueryHandler : IRequestHandler<GetNetworkStatis
RightCount = x.RightCount RightCount = x.RightCount
}).ToList(); }).ToList();
var (goldLeft, goldRight, silverLeft, silverRight) =
await LoadPackageHolderCountsByLegAsync(leftLegUserIds, rightLegUserIds, cancellationToken);
return new GetNetworkStatisticsResponseDto return new GetNetworkStatisticsResponseDto
{ {
TotalMembers = totalMembers, TotalMembers = totalMembers,
@@ -136,10 +151,93 @@ public class GetNetworkStatisticsQueryHandler : IRequestHandler<GetNetworkStatis
MaxDepth = maxDepth, MaxDepth = maxDepth,
LevelDistribution = levelDistribution, LevelDistribution = levelDistribution,
MonthlyGrowth = monthlyGrowth, MonthlyGrowth = monthlyGrowth,
TopUsers = topUserModels TopUsers = topUserModels,
GoldLeftLegTotal = goldLeft,
GoldRightLegTotal = goldRight,
SilverLeftLegTotal = silverLeft,
SilverRightLegTotal = silverRight
}; };
} }
/// <summary>
/// تعداد اعضای فعال باشگاه با پکیج جاری (IsCurrentCycle) در پای چپ/راست.
/// </summary>
private async Task<(int GoldLeft, int GoldRight, int SilverLeft, int SilverRight)> LoadPackageHolderCountsByLegAsync(
HashSet<long> leftLegUserIds,
HashSet<long> rightLegUserIds,
CancellationToken cancellationToken)
{
if (leftLegUserIds.Count == 0 && rightLegUserIds.Count == 0)
return (0, 0, 0, 0);
var (goldPackageId, silverPackageId) = await ResolveGoldSilverPackageIdsAsync(cancellationToken);
if (!goldPackageId.HasValue && !silverPackageId.HasValue)
return (0, 0, 0, 0);
var allLegUserIds = leftLegUserIds.Union(rightLegUserIds).ToList();
var userPackages = await (
from cm in _context.ClubMemberships.AsNoTracking()
join cc in _context.ClubMembershipCycles.AsNoTracking() on cm.UserId equals cc.UserId
where cm.IsActive
&& !cm.IsDeleted
&& cc.IsCurrentCycle
&& allLegUserIds.Contains(cm.UserId)
select new { cm.UserId, cc.PackageId }
).ToListAsync(cancellationToken);
var packageByUser = userPackages
.GroupBy(x => x.UserId)
.ToDictionary(g => g.Key, g => g.First().PackageId);
int goldLeft = 0, goldRight = 0, silverLeft = 0, silverRight = 0;
if (goldPackageId.HasValue)
{
goldLeft = leftLegUserIds.Count(id =>
packageByUser.TryGetValue(id, out var packageId) && packageId == goldPackageId.Value);
goldRight = rightLegUserIds.Count(id =>
packageByUser.TryGetValue(id, out var packageId) && packageId == goldPackageId.Value);
}
if (silverPackageId.HasValue)
{
silverLeft = leftLegUserIds.Count(id =>
packageByUser.TryGetValue(id, out var packageId) && packageId == silverPackageId.Value);
silverRight = rightLegUserIds.Count(id =>
packageByUser.TryGetValue(id, out var packageId) && packageId == silverPackageId.Value);
}
return (goldLeft, goldRight, silverLeft, silverRight);
}
private async Task<(long? GoldPackageId, long? SilverPackageId)> ResolveGoldSilverPackageIdsAsync(
CancellationToken cancellationToken)
{
var packages = await _context.Packages
.AsNoTracking()
.Where(p => !p.IsDeleted && p.IsActive)
.Select(p => new { p.Id, p.Title, p.IsBasePackage, p.MaxBalancesPerLeg })
.ToListAsync(cancellationToken);
var goldPackageId = packages.FirstOrDefault(p => p.IsBasePackage)?.Id
?? packages.FirstOrDefault(p => p.Title.Contains("طلایی") || p.Title.Contains("طلايی"))?.Id;
var silverPackageId = packages.FirstOrDefault(p =>
!p.IsBasePackage && (p.Title.Contains("نقره") || p.MaxBalancesPerLeg <= 30))?.Id
?? packages.FirstOrDefault(p => !p.IsBasePackage)?.Id;
return (goldPackageId, silverPackageId);
}
private HashSet<long> GetSubtreeUserIds(long rootUserId, List<User> allUsers)
{
var ids = new HashSet<long> { rootUserId };
foreach (var descendant in GetAllDescendants(rootUserId, allUsers))
ids.Add(descendant.Id);
return ids;
}
/// <summary> /// <summary>
/// Recursively get all descendants of a user /// Recursively get all descendants of a user
/// </summary> /// </summary>
@@ -2,9 +2,13 @@ namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkStat
public class GetNetworkStatisticsResponseDto public class GetNetworkStatisticsResponseDto
{ {
/// <summary>مجموع اعضای فعال باشگاه در پای چپ و راست (= LeftLegCount + RightLegCount)</summary>
public int TotalMembers { get; set; } public int TotalMembers { get; set; }
/// <summary>همان TotalMembers — اعضای فعال باشگاه در شبکه کاربر</summary>
public int ActiveMembers { get; set; } public int ActiveMembers { get; set; }
/// <summary>تعداد اعضای فعال باشگاه در زیردرخت پای چپ</summary>
public int LeftLegCount { get; set; } public int LeftLegCount { get; set; }
/// <summary>تعداد اعضای فعال باشگاه در زیردرخت پای راست</summary>
public int RightLegCount { get; set; } public int RightLegCount { get; set; }
public double LeftPercentage { get; set; } public double LeftPercentage { get; set; }
public double RightPercentage { get; set; } public double RightPercentage { get; set; }
@@ -13,6 +17,17 @@ public class GetNetworkStatisticsResponseDto
public List<LevelDistributionModel> LevelDistribution { get; set; } = new(); public List<LevelDistributionModel> LevelDistribution { get; set; } = new();
public List<MonthlyGrowthModel> MonthlyGrowth { get; set; } = new(); public List<MonthlyGrowthModel> MonthlyGrowth { get; set; } = new();
public List<TopNetworkUserModel> TopUsers { get; set; } = new(); public List<TopNetworkUserModel> TopUsers { get; set; } = new();
/// <summary>تعداد اعضای فعال باشگاه با پکیج طلایی جاری در پای چپ</summary>
public int GoldLeftLegTotal { get; set; }
/// <summary>تعداد اعضای فعال باشگاه با پکیج طلایی جاری در پای راست</summary>
public int GoldRightLegTotal { get; set; }
/// <summary>تعداد اعضای فعال باشگاه با پکیج نقره‌ای جاری در پای چپ</summary>
public int SilverLeftLegTotal { get; set; }
/// <summary>تعداد اعضای فعال باشگاه با پکیج نقره‌ای جاری در پای راست</summary>
public int SilverRightLegTotal { get; set; }
public string WeakerLeg => LeftLegCount <= RightLegCount ? "Left" : "Right";
} }
public class LevelDistributionModel public class LevelDistributionModel
@@ -1,5 +1,4 @@
using System.Data; using System.Data;
using System.Data.Common;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree; namespace CMSMicroservice.Application.NetworkMembershipCQ.Queries.GetNetworkTree;
@@ -60,6 +60,18 @@ public class NetworkTreeDto
public long? PackageId { get; set; } public long? PackageId { get; set; }
/// <summary>تعداد اعضای فعال با پکیج طلایی جاری در پای چپ (stats bar)</summary>
public int GoldLeftLegTotal { get; set; }
/// <summary>تعداد اعضای فعال با پکیج طلایی جاری در پای راست (stats bar)</summary>
public int GoldRightLegTotal { get; set; }
/// <summary>تعداد اعضای فعال با پکیج نقره‌ای جاری در پای چپ (stats bar)</summary>
public int SilverLeftLegTotal { get; set; }
/// <summary>تعداد اعضای فعال با پکیج نقره‌ای جاری در پای راست (stats bar)</summary>
public int SilverRightLegTotal { get; set; }
public NetworkTreeDto? LeftChild { get; set; } public NetworkTreeDto? LeftChild { get; set; }
public NetworkTreeDto? RightChild { get; set; } public NetworkTreeDto? RightChild { get; set; }
} }
@@ -8,6 +8,7 @@ using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
using MediatR; using MediatR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment; namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment;
@@ -16,20 +17,23 @@ public class VerifyUserPackagePurchasePaymentCommandHandler
: IRequestHandler<VerifyUserPackagePurchasePaymentCommand, VerifyUserPackagePurchasePaymentResult> : IRequestHandler<VerifyUserPackagePurchasePaymentCommand, VerifyUserPackagePurchasePaymentResult>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayResolver _paymentGatewayResolver;
private readonly IConfiguration _configuration;
private readonly ISender _sender; private readonly ISender _sender;
private readonly IUserPaymentLock _paymentLock; private readonly IUserPaymentLock _paymentLock;
private readonly ILogger<VerifyUserPackagePurchasePaymentCommandHandler> _logger; private readonly ILogger<VerifyUserPackagePurchasePaymentCommandHandler> _logger;
public VerifyUserPackagePurchasePaymentCommandHandler( public VerifyUserPackagePurchasePaymentCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IPaymentGatewayService paymentGateway, IPaymentGatewayResolver paymentGatewayResolver,
IConfiguration configuration,
ISender sender, ISender sender,
IUserPaymentLock paymentLock, IUserPaymentLock paymentLock,
ILogger<VerifyUserPackagePurchasePaymentCommandHandler> logger) ILogger<VerifyUserPackagePurchasePaymentCommandHandler> logger)
{ {
_context = context; _context = context;
_paymentGateway = paymentGateway; _paymentGatewayResolver = paymentGatewayResolver;
_configuration = configuration;
_sender = sender; _sender = sender;
_paymentLock = paymentLock; _paymentLock = paymentLock;
_logger = logger; _logger = logger;
@@ -105,7 +109,8 @@ public class VerifyUserPackagePurchasePaymentCommandHandler
} }
var amountInToman = paymentTx?.Amount ?? purchase.Amount; var amountInToman = paymentTx?.Amount ?? purchase.Amount;
var verifyResult = await _paymentGateway.VerifyPaymentAsync( var gateway = _paymentGatewayResolver.Get(paymentTx?.GatewayProvider);
var verifyResult = await gateway.VerifyPaymentAsync(
request.Authority, request.Status, (decimal)amountInToman, ct); request.Authority, request.Status, (decimal)amountInToman, ct);
if (!verifyResult.IsSuccess) if (!verifyResult.IsSuccess)
@@ -193,17 +198,30 @@ public class VerifyUserPackagePurchasePaymentCommandHandler
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
var discountMultiplier = purchase.Package?.DiscountMultiplier var packagePrice = purchase.Package?.Price
?? throw new InvalidOperationException("پکیج سفارش یافت نشد"); ?? throw new InvalidOperationException("پکیج سفارش یافت نشد");
var discountAmount = (long)(purchase.Amount * (double)discountMultiplier); var discountMultiplier = purchase.Package.DiscountMultiplier;
wallet.Balance += purchase.Amount; var discountAmount = (long)(packagePrice * (double)discountMultiplier);
var isTwentyCard = string.Equals(
paymentTx?.GatewayProvider, "20card", StringComparison.OrdinalIgnoreCase);
long balanceCredit = purchase.Amount;
if (isTwentyCard)
{
balanceCredit = _configuration.GetValue<long?>(
$"TwentyCard:Packages:{purchase.PackageId}:WalletCreditToman") ?? 0;
if (balanceCredit <= 0)
throw new InvalidOperationException("WalletCredit ۲۰کارت برای این پکیج پیکربندی نشده است");
}
wallet.Balance += balanceCredit;
wallet.DiscountBalance += discountAmount; wallet.DiscountBalance += discountAmount;
_context.UserWalletHistories.Add(new UserWalletHistory _context.UserWalletHistories.Add(new UserWalletHistory
{ {
WalletId = wallet.Id, WalletId = wallet.Id,
CurrentBalance = wallet.Balance, CurrentBalance = wallet.Balance,
ChangeValue = purchase.Amount, ChangeValue = balanceCredit,
CurrentNetworkBalance = wallet.NetworkBalance, CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0, ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance, CurrentDiscountBalance = wallet.DiscountBalance,
@@ -43,4 +43,5 @@ public class GetAllPaymentTransactionByFilterResponseModel
public long? TransactionId { get; set; } public long? TransactionId { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public DateTimeOffset Created { get; set; } public DateTimeOffset Created { get; set; }
public string? UserFullName { get; set; }
} }
@@ -19,30 +19,43 @@ public class GetAllPaymentTransactionByFilterQueryHandler
.ApplyPaymentTransactionFilter(request.Filter) .ApplyPaymentTransactionFilter(request.Filter)
.ApplyOrder(sortBy: request.SortBy ?? "-Created"); .ApplyOrder(sortBy: request.SortBy ?? "-Created");
var models = await (
from x in query.PaginatedListAsync(paginationState: request.PaginationState)
join u in _context.Users.AsNoTracking() on x.UserId equals u.Id into ug
from u in ug.DefaultIfEmpty()
select new GetAllPaymentTransactionByFilterResponseModel
{
Id = x.Id,
UserId = x.UserId,
Mobile = x.Mobile,
GatewayProvider = x.GatewayProvider,
Amount = x.Amount,
Authority = x.Authority,
PaymentStatus = x.PaymentStatus,
VerificationStatusCode = x.VerificationStatusCode,
VerificationStatusMessage = x.VerificationStatusMessage,
RefId = x.RefId,
CardPan = x.CardPan,
OrderId = x.OrderId,
TransactionId = x.TransactionId,
Description = x.Description,
Created = x.Created,
UserFullName = u != null
? ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim()
: null
})
.ToListAsync(cancellationToken);
foreach (var model in models)
{
if (string.IsNullOrWhiteSpace(model.UserFullName))
model.UserFullName = "—";
}
return new GetAllPaymentTransactionByFilterResponseDto return new GetAllPaymentTransactionByFilterResponseDto
{ {
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken), MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query Models = models
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetAllPaymentTransactionByFilterResponseModel
{
Id = x.Id,
UserId = x.UserId,
Mobile = x.Mobile,
GatewayProvider = x.GatewayProvider,
Amount = x.Amount,
Authority = x.Authority,
PaymentStatus = x.PaymentStatus,
VerificationStatusCode = x.VerificationStatusCode,
VerificationStatusMessage = x.VerificationStatusMessage,
RefId = x.RefId,
CardPan = x.CardPan,
OrderId = x.OrderId,
TransactionId = x.TransactionId,
Description = x.Description,
Created = x.Created
})
.ToListAsync(cancellationToken)
}; };
} }
} }
@@ -67,11 +67,8 @@ public class GetCustomerProductsByFilterQueryHandler : IRequestHandler<GetCustom
if (request.IsActive.HasValue) if (request.IsActive.HasValue)
query = query.Where(x => x.IsDeleted != request.IsActive.Value); query = query.Where(x => x.IsDeleted != request.IsActive.Value);
// Apply sorting // موجودی‌دارها اول، سپس سورت درخواستی
if (!string.IsNullOrEmpty(request.SortBy)) query = query.ApplyOrderWithInStockPriority(request.SortBy, x => x.RemainingCount > 0);
query = query.ApplyOrder(request.SortBy);
else
query = query.OrderByDescending(x => x.Created);
// Pagination // Pagination
var totalCount = await query.CountAsync(cancellationToken); var totalCount = await query.CountAsync(cancellationToken);
@@ -1,3 +1,5 @@
using CMSMicroservice.Application.Common;
namespace CMSMicroservice.Application.TransactionsCQ.Queries.GetAllTransactionsByFilter; namespace CMSMicroservice.Application.TransactionsCQ.Queries.GetAllTransactionsByFilter;
public class GetAllTransactionsByFilterQueryHandler : IRequestHandler<GetAllTransactionsByFilterQuery, GetAllTransactionsByFilterResponseDto> public class GetAllTransactionsByFilterQueryHandler : IRequestHandler<GetAllTransactionsByFilterQuery, GetAllTransactionsByFilterResponseDto>
@@ -16,23 +18,106 @@ public class GetAllTransactionsByFilterQueryHandler : IRequestHandler<GetAllTran
.ApplyTransactionFilter(request.Filter) .ApplyTransactionFilter(request.Filter)
.ApplyOrder(sortBy: request.SortBy ?? "-Created"); .ApplyOrder(sortBy: request.SortBy ?? "-Created");
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetAllTransactionsByFilterResponseModel
{
Id = x.Id,
Amount = x.Amount,
Description = x.Description,
PaymentStatus = x.PaymentStatus,
PaymentDate = x.PaymentDate,
RefId = x.RefId,
Type = x.Type,
Created = x.Created
})
.ToListAsync(cancellationToken);
await PopulateUserInfoAsync(models, cancellationToken);
return new GetAllTransactionsByFilterResponseDto return new GetAllTransactionsByFilterResponseDto
{ {
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken), MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query Models = models
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetAllTransactionsByFilterResponseModel
{
Id = x.Id,
Amount = x.Amount,
Description = x.Description,
PaymentStatus = x.PaymentStatus,
PaymentDate = x.PaymentDate,
RefId = x.RefId,
Type = x.Type,
Created = x.Created
})
.ToListAsync(cancellationToken)
}; };
} }
private async Task PopulateUserInfoAsync(
List<GetAllTransactionsByFilterResponseModel> models,
CancellationToken cancellationToken)
{
if (models.Count == 0)
return;
var txIds = models.Select(m => m.Id).ToList();
var userByTx = new Dictionary<long, long>();
void Merge(IEnumerable<(long TxId, long UserId)> rows)
{
foreach (var (txId, userId) in rows)
{
if (!userByTx.ContainsKey(txId))
userByTx[txId] = userId;
}
}
// Priority: PaymentTransactions → UserPackagePurchases → UserOrders → ManualPayments
var fromPt = await _context.PaymentTransactions.AsNoTracking()
.Where(x => x.TransactionId != null && txIds.Contains(x.TransactionId.Value) && x.UserId != null)
.Select(x => new { TxId = x.TransactionId!.Value, UserId = x.UserId!.Value })
.ToListAsync(cancellationToken);
Merge(fromPt.Select(x => (x.TxId, x.UserId)));
var fromUpp = await _context.UserPackagePurchases.AsNoTracking()
.Where(x => x.TransactionId != null && txIds.Contains(x.TransactionId.Value))
.Select(x => new { TxId = x.TransactionId!.Value, UserId = x.UserId })
.ToListAsync(cancellationToken);
Merge(fromUpp.Select(x => (x.TxId, x.UserId)));
var fromOrders = await _context.UserOrders.AsNoTracking()
.Where(x => x.TransactionId != null && txIds.Contains(x.TransactionId.Value))
.Select(x => new { TxId = x.TransactionId!.Value, UserId = x.UserId })
.ToListAsync(cancellationToken);
Merge(fromOrders.Select(x => (x.TxId, x.UserId)));
var fromManual = await _context.ManualPayments.AsNoTracking()
.Where(x => x.TransactionId != null && txIds.Contains(x.TransactionId.Value))
.Select(x => new { TxId = x.TransactionId!.Value, UserId = x.UserId })
.ToListAsync(cancellationToken);
Merge(fromManual.Select(x => (x.TxId, x.UserId)));
if (userByTx.Count == 0)
{
foreach (var model in models)
model.UserFullName = "—";
return;
}
var userIds = userByTx.Values.Distinct().ToList();
var users = await _context.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.FirstName, u.LastName, u.Mobile })
.ToDictionaryAsync(u => u.Id, cancellationToken);
foreach (var model in models)
{
if (!userByTx.TryGetValue(model.Id, out var userId))
{
model.UserFullName = "—";
continue;
}
model.UserId = userId;
if (users.TryGetValue(userId, out var user))
{
model.Mobile = user.Mobile;
var name = UserDisplayName.From(user.FirstName, user.LastName, user.Mobile, user.Id);
model.UserFullName = string.IsNullOrWhiteSpace(name) ? "—" : name;
}
else
{
model.UserFullName = "—";
}
}
}
} }
@@ -25,4 +25,7 @@ public class GetAllTransactionsByFilterResponseDto
// //
public TransactionType Type { get; set; } public TransactionType Type { get; set; }
public DateTime Created { get; set; } public DateTime Created { get; set; }
public long? UserId { get; set; }
public string? UserFullName { get; set; }
public string? Mobile { get; set; }
} }
@@ -9,5 +9,7 @@ public record VerifyOtpTokenCommand : IRequest<VerifyOtpTokenResponseDto>
public string Code { get; init; } public string Code { get; init; }
//کد معرف والد //کد معرف والد
public string? ParentReferralCode { get; init; } public string? ParentReferralCode { get; init; }
//شماره موبایل دعوت‌کننده (اختیاری)
public string? InviterMobile { get; init; }
} }
@@ -118,6 +118,34 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
else else
return new VerifyOtpTokenResponseDto { Success = false, Message = "ظرفیت معرف تکمیل است!!" }; return new VerifyOtpTokenResponseDto { Success = false, Message = "ظرفیت معرف تکمیل است!!" };
// ── دعوت‌کننده (اختیاری؛ اعتبارسنجی سخت قبل از ایجاد کاربر) ──
User? inviter = null;
if (!string.IsNullOrWhiteSpace(request.InviterMobile))
{
var inviterMobile = request.InviterMobile.NormalizeIranMobile();
if (inviterMobile == mobile)
{
return new VerifyOtpTokenResponseDto
{
Success = false,
Message = "شماره موبایل دعوت‌کننده نمی‌تواند با شماره خودتان یکسان باشد."
};
}
inviter = await _context.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Mobile == inviterMobile, cancellationToken);
if (inviter == null)
{
return new VerifyOtpTokenResponseDto
{
Success = false,
Message = "دعوت‌کننده با این شماره موبایل یافت نشد."
};
}
}
// ایجاد کاربر جدید // ایجاد کاربر جدید
user = new User user = new User
{ {
@@ -134,6 +162,15 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
user.AddDomainEvent(new CreateNewUserEvent(user)); user.AddDomainEvent(new CreateNewUserEvent(user));
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
if (inviter != null)
{
await _context.UserInvitations.AddAsync(new UserInvitation
{
InviterUserId = inviter.Id,
InviteeUserId = user.Id
}, cancellationToken);
}
// ایجاد نقش کاربری // ایجاد نقش کاربری
var userRole = new UserRole { UserId = user.Id, RoleId = 1 }; var userRole = new UserRole { UserId = user.Id, RoleId = 1 };
await _context.UserRoles.AddAsync(userRole, cancellationToken); await _context.UserRoles.AddAsync(userRole, cancellationToken);
@@ -17,7 +17,7 @@ public class GetAllUserByFilterQueryHandler : IRequestHandler<GetAllUserByFilter
if (request.Filter is not null) if (request.Filter is not null)
{ {
query = query query = query
.Where(x => request.Filter.SearchText == null || .Where(x => request.Filter.SearchText == null ||
x.FirstName.Contains(request.Filter.SearchText) || x.FirstName.Contains(request.Filter.SearchText) ||
x.LastName.Contains(request.Filter.SearchText) || x.LastName.Contains(request.Filter.SearchText) ||
x.Mobile.Contains(request.Filter.SearchText) || x.Mobile.Contains(request.Filter.SearchText) ||
@@ -34,14 +34,39 @@ public class GetAllUserByFilterQueryHandler : IRequestHandler<GetAllUserByFilter
.Where(x => request.Filter.SmsNotifications == null || x.SmsNotifications == request.Filter.SmsNotifications) .Where(x => request.Filter.SmsNotifications == null || x.SmsNotifications == request.Filter.SmsNotifications)
.Where(x => request.Filter.EmailNotifications == null || x.EmailNotifications == request.Filter.EmailNotifications) .Where(x => request.Filter.EmailNotifications == null || x.EmailNotifications == request.Filter.EmailNotifications)
.Where(x => request.Filter.PushNotifications == null || x.PushNotifications == request.Filter.PushNotifications) .Where(x => request.Filter.PushNotifications == null || x.PushNotifications == request.Filter.PushNotifications)
.Where(x => request.Filter.BirthDate == null || x.BirthDate == request.Filter.BirthDate) .Where(x => request.Filter.BirthDate == null || x.BirthDate == request.Filter.BirthDate);
;
} }
return new GetAllUserByFilterResponseDto return new GetAllUserByFilterResponseDto
{ {
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken), MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query.PaginatedListAsync(paginationState: request.PaginationState) Models = await query.PaginatedListAsync(paginationState: request.PaginationState)
.ProjectToType<GetAllUserByFilterResponseModel>().ToListAsync(cancellationToken) .Select(x => new GetAllUserByFilterResponseModel
}; {
Id = x.Id,
FirstName = x.FirstName,
LastName = x.LastName,
Mobile = x.Mobile,
NationalCode = x.NationalCode,
AvatarPath = x.AvatarPath,
NetworkParentId = x.NetworkParentId,
ReferralCode = x.ReferralCode,
IsMobileVerified = x.IsMobileVerified,
MobileVerifiedAt = x.MobileVerifiedAt,
EmailNotifications = x.EmailNotifications,
SmsNotifications = x.SmsNotifications,
PushNotifications = x.PushNotifications,
BirthDate = x.BirthDate,
DefaultAddress = x.UserAddresses
.Where(a => a.IsDefault && !a.IsDeleted)
.Select(a => a.Address)
.FirstOrDefault() ?? string.Empty,
DefaultPostalCode = x.UserAddresses
.Where(a => a.IsDefault && !a.IsDeleted)
.Select(a => a.PostalCode)
.FirstOrDefault() ?? string.Empty
})
.ToListAsync(cancellationToken)
};
} }
} }
@@ -36,4 +36,8 @@ public class GetAllUserByFilterResponseDto
public bool PushNotifications { get; set; } public bool PushNotifications { get; set; }
//تاریخ تولد //تاریخ تولد
public DateTime? BirthDate { get; set; } public DateTime? BirthDate { get; set; }
//آدرس پیش‌فرض
public string DefaultAddress { get; set; } = string.Empty;
//کدپستی پیش‌فرض
public string DefaultPostalCode { get; set; } = string.Empty;
} }
@@ -0,0 +1,46 @@
using CMSMicroservice.Application.Common.Models;
using MediatR;
namespace CMSMicroservice.Application.UserCQ.Queries.GetInvitationsByFilter;
public class GetInvitationsByFilterQuery : IRequest<GetInvitationsByFilterResult>
{
public PaginationState? PaginationState { get; set; }
public string? SortBy { get; set; }
public GetInvitationsByFilterFilterDto? Filter { get; set; }
}
public class GetInvitationsByFilterFilterDto
{
public string? InviterMobile { get; set; }
public string? InviterFirstName { get; set; }
public string? InviterLastName { get; set; }
public string? InviteeMobile { get; set; }
public string? InviteeFirstName { get; set; }
public string? InviteeLastName { get; set; }
public bool? InviteeClubActive { get; set; }
public DateTime? CreatedFrom { get; set; }
public DateTime? CreatedTo { get; set; }
}
public class GetInvitationsByFilterResult
{
public MetaData MetaData { get; set; } = new();
public List<InvitationReportDto> Models { get; set; } = new();
}
public class InvitationReportDto
{
public long Id { get; set; }
public long InviterUserId { get; set; }
public string? InviterFirstName { get; set; }
public string? InviterLastName { get; set; }
public string InviterMobile { get; set; } = string.Empty;
public long InviteeUserId { get; set; }
public string? InviteeFirstName { get; set; }
public string? InviteeLastName { get; set; }
public string InviteeMobile { get; set; } = string.Empty;
public DateTime InviteeRegisteredAt { get; set; }
public bool InviteeClubActive { get; set; }
public DateTime InvitedAt { get; set; }
}
@@ -0,0 +1,96 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.UserCQ.Queries.GetInvitationsByFilter;
public class GetInvitationsByFilterQueryHandler
: IRequestHandler<GetInvitationsByFilterQuery, GetInvitationsByFilterResult>
{
private readonly IApplicationDbContext _context;
public GetInvitationsByFilterQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetInvitationsByFilterResult> Handle(
GetInvitationsByFilterQuery request,
CancellationToken cancellationToken)
{
var filter = request.Filter ?? new GetInvitationsByFilterFilterDto();
var query = _context.UserInvitations
.AsNoTracking()
.Select(x => new InvitationReportDto
{
Id = x.Id,
InviterUserId = x.InviterUserId,
InviterFirstName = x.Inviter.FirstName,
InviterLastName = x.Inviter.LastName,
InviterMobile = x.Inviter.Mobile,
InviteeUserId = x.InviteeUserId,
InviteeFirstName = x.Invitee.FirstName,
InviteeLastName = x.Invitee.LastName,
InviteeMobile = x.Invitee.Mobile,
InviteeRegisteredAt = x.Invitee.Created,
InviteeClubActive = x.Invitee.ClubMembership != null && x.Invitee.ClubMembership.IsActive,
InvitedAt = x.Created
});
if (!string.IsNullOrWhiteSpace(filter.InviterMobile))
query = query.Where(x => x.InviterMobile.Contains(filter.InviterMobile));
if (!string.IsNullOrWhiteSpace(filter.InviterFirstName))
query = query.Where(x => x.InviterFirstName != null && x.InviterFirstName.Contains(filter.InviterFirstName));
if (!string.IsNullOrWhiteSpace(filter.InviterLastName))
query = query.Where(x => x.InviterLastName != null && x.InviterLastName.Contains(filter.InviterLastName));
if (!string.IsNullOrWhiteSpace(filter.InviteeMobile))
query = query.Where(x => x.InviteeMobile.Contains(filter.InviteeMobile));
if (!string.IsNullOrWhiteSpace(filter.InviteeFirstName))
query = query.Where(x => x.InviteeFirstName != null && x.InviteeFirstName.Contains(filter.InviteeFirstName));
if (!string.IsNullOrWhiteSpace(filter.InviteeLastName))
query = query.Where(x => x.InviteeLastName != null && x.InviteeLastName.Contains(filter.InviteeLastName));
if (filter.InviteeClubActive.HasValue)
query = query.Where(x => x.InviteeClubActive == filter.InviteeClubActive.Value);
if (filter.CreatedFrom.HasValue)
query = query.Where(x => x.InvitedAt >= filter.CreatedFrom.Value);
if (filter.CreatedTo.HasValue)
query = query.Where(x => x.InvitedAt <= filter.CreatedTo.Value);
// پیش‌فرض: جدیدترین
query = (request.SortBy?.Trim().ToLowerInvariant()) switch
{
"oldest" or "created_asc" => query.OrderBy(x => x.InvitedAt),
_ => query.OrderByDescending(x => x.InvitedAt)
};
var paginationState = request.PaginationState ?? new PaginationState { PageNumber = 1, PageSize = 20 };
if (paginationState.PageNumber < 1) paginationState.PageNumber = 1;
if (paginationState.PageSize < 1) paginationState.PageSize = 20;
var totalCount = await query.CountAsync(cancellationToken);
var models = await query
.Skip((paginationState.PageNumber - 1) * paginationState.PageSize)
.Take(paginationState.PageSize)
.ToListAsync(cancellationToken);
var totalPage = totalCount == 0
? 0
: (int)Math.Ceiling((double)totalCount / paginationState.PageSize);
return new GetInvitationsByFilterResult
{
MetaData = new MetaData
{
TotalCount = totalCount,
CurrentPage = paginationState.PageNumber,
PageSize = paginationState.PageSize,
TotalPage = totalPage,
HasPrevious = paginationState.PageNumber > 1,
HasNext = paginationState.PageNumber < totalPage
},
Models = models
};
}
}
@@ -0,0 +1,36 @@
using CMSMicroservice.Application.Common.Models;
using MediatR;
namespace CMSMicroservice.Application.UserCQ.Queries.GetMyInvitees;
public class GetMyInviteesQuery : IRequest<GetMyInviteesResult>
{
public long UserId { get; set; }
public PaginationState? PaginationState { get; set; }
/// <summary>ALL | ACTIVE | INACTIVE — ClubMembership.IsActive</summary>
public string? ClubStatusFilter { get; set; }
}
public class GetMyInviteesResult
{
public MetaData MetaData { get; set; } = new();
public List<InviteeDto> Invitees { get; set; } = new();
public InviteeStatsDto Stats { get; set; } = new();
}
public class InviteeDto
{
public long UserId { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string Mobile { get; set; } = string.Empty;
public DateTime RegisteredAt { get; set; }
public bool IsClubActive { get; set; }
}
public class InviteeStatsDto
{
public int TotalInvitees { get; set; }
public int ClubActiveCount { get; set; }
public int ClubInactiveCount { get; set; }
}
@@ -0,0 +1,98 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.UserCQ.Queries.GetMyInvitees;
public class GetMyInviteesQueryHandler : IRequestHandler<GetMyInviteesQuery, GetMyInviteesResult>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
public GetMyInviteesQueryHandler(IApplicationDbContext context, ICurrentUserService currentUser)
{
_context = context;
_currentUser = currentUser;
}
public async Task<GetMyInviteesResult> Handle(GetMyInviteesQuery request, CancellationToken cancellationToken)
{
var userId = request.UserId == 0
? (long.TryParse(_currentUser.UserId, out var id) ? id : 0)
: request.UserId;
if (userId == 0)
throw new UnauthorizedAccessException("User ID not found in JWT token");
var baseQuery = _context.UserInvitations
.AsNoTracking()
.Where(x => x.InviterUserId == userId)
.Select(x => new
{
x.InviteeUserId,
InviteeFirstName = x.Invitee.FirstName,
InviteeLastName = x.Invitee.LastName,
InviteeMobile = x.Invitee.Mobile,
RegisteredAt = x.Invitee.Created,
IsClubActive = x.Invitee.ClubMembership != null && x.Invitee.ClubMembership.IsActive,
InvitedAt = x.Created
});
var all = await baseQuery.ToListAsync(cancellationToken);
var total = all.Count;
var clubActive = all.Count(x => x.IsClubActive);
var clubInactive = total - clubActive;
var filtered = all.AsEnumerable();
var status = request.ClubStatusFilter?.Trim().ToUpperInvariant();
if (status == "ACTIVE")
filtered = filtered.Where(x => x.IsClubActive);
else if (status == "INACTIVE")
filtered = filtered.Where(x => !x.IsClubActive);
var ordered = filtered.OrderByDescending(x => x.InvitedAt).ToList();
var paginationState = request.PaginationState ?? new PaginationState { PageNumber = 1, PageSize = 10 };
if (paginationState.PageNumber < 1) paginationState.PageNumber = 1;
if (paginationState.PageSize < 1) paginationState.PageSize = 10;
var totalCount = ordered.Count;
var pageItems = ordered
.Skip((paginationState.PageNumber - 1) * paginationState.PageSize)
.Take(paginationState.PageSize)
.ToList();
var totalPage = totalCount == 0
? 0
: (int)Math.Ceiling((double)totalCount / paginationState.PageSize);
return new GetMyInviteesResult
{
MetaData = new MetaData
{
TotalCount = totalCount,
CurrentPage = paginationState.PageNumber,
PageSize = paginationState.PageSize,
TotalPage = totalPage,
HasPrevious = paginationState.PageNumber > 1,
HasNext = paginationState.PageNumber < totalPage
},
Stats = new InviteeStatsDto
{
TotalInvitees = total,
ClubActiveCount = clubActive,
ClubInactiveCount = clubInactive
},
Invitees = pageItems.Select(x => new InviteeDto
{
UserId = x.InviteeUserId,
FirstName = x.InviteeFirstName,
LastName = x.InviteeLastName,
Mobile = x.InviteeMobile,
RegisteredAt = x.RegisteredAt,
IsClubActive = x.IsClubActive
}).ToList()
};
}
}
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities; using CMSMicroservice.Domain.Entities;
@@ -50,8 +51,10 @@ public class GetCustomerOrderQueryHandler : IRequestHandler<GetCustomerOrderQuer
DeliveryStatus = order.DeliveryStatus, DeliveryStatus = order.DeliveryStatus,
TrackingCode = order.TrackingCode ?? "", TrackingCode = order.TrackingCode ?? "",
DeliveryDescription = order.DeliveryDescription ?? "", DeliveryDescription = order.DeliveryDescription ?? "",
UserFullName = $"{order.User?.FirstName ?? ""} {order.User?.LastName ?? ""}".Trim(), UserFullName = UserDisplayName.From(order.User),
UserNationalCode = order.User?.NationalCode ?? "", UserNationalCode = order.User?.NationalCode ?? "",
PostalCode = order.UserAddress?.PostalCode ?? "",
UserMobile = order.User?.Mobile ?? "",
VatAmount = order.OrderVAT?.VATAmount ?? 0, VatAmount = order.OrderVAT?.VATAmount ?? 0,
VatPercentage = order.OrderVAT != null ? (double)order.OrderVAT.VATRate * 100 : 0, VatPercentage = order.OrderVAT != null ? (double)order.OrderVAT.VATRate * 100 : 0,
FactorDetails = order.FactorDetails?.Select(fd => new FactorDetailDto FactorDetails = order.FactorDetails?.Select(fd => new FactorDetailDto
@@ -20,6 +20,8 @@ public class GetCustomerOrderResponseDto
public string DeliveryDescription { get; set; } public string DeliveryDescription { get; set; }
public string UserFullName { get; set; } public string UserFullName { get; set; }
public string UserNationalCode { get; set; } public string UserNationalCode { get; set; }
public string PostalCode { get; set; } = string.Empty;
public string UserMobile { get; set; } = string.Empty;
public long VatAmount { get; set; } public long VatAmount { get; set; }
public double VatPercentage { get; set; } public double VatPercentage { get; set; }
} }
@@ -113,6 +113,7 @@ public class GetCustomerOrderHistoryQueryHandler : IRequestHandler<GetCustomerOr
DeliveryStatus.Delivered => 4, DeliveryStatus.Delivered => 4,
DeliveryStatus.Cancelled => 5, DeliveryStatus.Cancelled => 5,
DeliveryStatus.Returned => 6, DeliveryStatus.Returned => 6,
DeliveryStatus.ReadyForOfficePickup => 7,
_ => 0 _ => 0
}; };
} }
@@ -127,6 +128,7 @@ public class GetCustomerOrderHistoryQueryHandler : IRequestHandler<GetCustomerOr
DeliveryStatus.Delivered => "تحویل داده شد", DeliveryStatus.Delivered => "تحویل داده شد",
DeliveryStatus.Cancelled => "لغو شده", DeliveryStatus.Cancelled => "لغو شده",
DeliveryStatus.Returned => "مرجوع شده", DeliveryStatus.Returned => "مرجوع شده",
DeliveryStatus.ReadyForOfficePickup => "آماده تحویل در دفتر",
_ => "نامشخص" _ => "نامشخص"
}; };
} }
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Extensions; using CMSMicroservice.Application.Common.Extensions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models; using CMSMicroservice.Application.Common.Models;
@@ -72,7 +73,7 @@ public class GetCustomerOrdersQueryHandler : IRequestHandler<GetCustomerOrdersQu
DeliveryStatus = order.DeliveryStatus, DeliveryStatus = order.DeliveryStatus,
TrackingCode = order.TrackingCode ?? "", TrackingCode = order.TrackingCode ?? "",
DeliveryDescription = order.DeliveryDescription ?? "", DeliveryDescription = order.DeliveryDescription ?? "",
UserFullName = $"{order.User?.FirstName ?? ""} {order.User?.LastName ?? ""}".Trim(), UserFullName = UserDisplayName.From(order.User),
UserNationalCode = order.User?.NationalCode ?? "", UserNationalCode = order.User?.NationalCode ?? "",
VatAmount = order.OrderVAT?.VATAmount ?? 0, VatAmount = order.OrderVAT?.VATAmount ?? 0,
VatPercentage = order.OrderVAT != null ? (double)order.OrderVAT.VATRate * 100 : 0, VatPercentage = order.OrderVAT != null ? (double)order.OrderVAT.VATRate * 100 : 0,
@@ -0,0 +1,51 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetCustomerPackagePurchaseRollup;
public record GetCustomerPackagePurchaseRollupQuery : IRequest<GetCustomerPackagePurchaseRollupResponseDto>
{
public PaginationState? PaginationState { get; init; }
public GetCustomerPackagePurchaseRollupFilter? Filter { get; init; }
}
public class GetCustomerPackagePurchaseRollupFilter
{
public long? UserId { get; set; }
public PackagePurchaseMethod? PurchaseMethod { get; set; }
public DateTime? PurchasedFrom { get; set; }
public DateTime? PurchasedTo { get; set; }
}
public class GetCustomerPackagePurchaseRollupResponseDto
{
public MetaData MetaData { get; set; } = new();
public List<CustomerPackagePurchaseRollupModel> Models { get; set; } = new();
}
public class CustomerPackagePurchaseRollupModel
{
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public string UserMobile { get; set; } = string.Empty;
public long FirstPackageId { get; set; }
public string FirstPackageName { get; set; } = string.Empty;
public long FirstAmount { get; set; }
public DateTime FirstPurchasedAt { get; set; }
public long LastPackageId { get; set; }
public string LastPackageName { get; set; } = string.Empty;
public long LastAmount { get; set; }
public DateTime LastPurchasedAt { get; set; }
public PackagePurchaseMethod LastPurchaseMethod { get; set; }
public int PurchaseCount { get; set; }
public long TotalAmount { get; set; }
public int DayaCount { get; set; }
public long DayaAmount { get; set; }
public int ManualCount { get; set; }
public long ManualAmount { get; set; }
public int GatewayCount { get; set; }
public long GatewayAmount { get; set; }
}
@@ -0,0 +1,136 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetCustomerPackagePurchaseRollup;
public class GetCustomerPackagePurchaseRollupQueryHandler
: IRequestHandler<GetCustomerPackagePurchaseRollupQuery, GetCustomerPackagePurchaseRollupResponseDto>
{
private readonly IApplicationDbContext _context;
public GetCustomerPackagePurchaseRollupQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetCustomerPackagePurchaseRollupResponseDto> Handle(
GetCustomerPackagePurchaseRollupQuery request,
CancellationToken cancellationToken)
{
var filter = request.Filter;
var pagination = request.PaginationState ?? new PaginationState { PageNumber = 1, PageSize = 20 };
if (pagination.PageNumber < 1) pagination.PageNumber = 1;
// PageSize == 0 → خروجی کامل (بدون صفحه‌بندی)، مثلاً Excel
var exportAll = pagination.PageSize == 0;
if (!exportAll && pagination.PageSize < 1) pagination.PageSize = 20;
// خرید معتبر: بدون تراکنش یا تراکنش موفق
var purchases = _context.UserPackagePurchases
.AsNoTracking()
.Include(x => x.User)
.Include(x => x.Package)
.Include(x => x.Transaction)
.Where(x => x.TransactionId == null
|| (x.Transaction != null && x.Transaction.PaymentStatus == PaymentStatus.Success));
if (filter?.UserId is > 0)
purchases = purchases.Where(x => x.UserId == filter.UserId.Value);
if (filter?.PurchaseMethod != null)
purchases = purchases.Where(x => x.PurchaseMethod == filter.PurchaseMethod.Value);
if (filter?.PurchasedFrom != null)
purchases = purchases.Where(x => x.PurchasedAt >= filter.PurchasedFrom.Value);
if (filter?.PurchasedTo != null)
purchases = purchases.Where(x => x.PurchasedAt <= filter.PurchasedTo.Value);
var rows = await purchases
.Select(x => new
{
x.UserId,
FirstName = x.User != null ? x.User.FirstName : null,
LastName = x.User != null ? x.User.LastName : null,
Mobile = x.User != null ? x.User.Mobile : null,
x.PackageId,
PackageName = x.Package != null ? x.Package.Title : null,
x.Amount,
x.PurchasedAt,
x.PurchaseMethod
})
.ToListAsync(cancellationToken);
var grouped = rows
.GroupBy(x => x.UserId)
.Select(g =>
{
var ordered = g.OrderBy(x => x.PurchasedAt).ThenBy(x => x.PackageId).ToList();
var first = ordered.First();
var last = ordered.Last();
var userName = string.Join(" ",
new[] { first.FirstName, first.LastName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
return new CustomerPackagePurchaseRollupModel
{
UserId = g.Key,
UserName = string.IsNullOrWhiteSpace(userName)
? (!string.IsNullOrWhiteSpace(first.Mobile) ? first.Mobile! : $"کاربر {g.Key}")
: userName,
UserMobile = first.Mobile ?? string.Empty,
FirstPackageId = first.PackageId,
FirstPackageName = first.PackageName ?? string.Empty,
FirstAmount = first.Amount,
FirstPurchasedAt = first.PurchasedAt,
LastPackageId = last.PackageId,
LastPackageName = last.PackageName ?? string.Empty,
LastAmount = last.Amount,
LastPurchasedAt = last.PurchasedAt,
LastPurchaseMethod = last.PurchaseMethod,
PurchaseCount = ordered.Count,
TotalAmount = ordered.Sum(x => x.Amount),
DayaCount = ordered.Count(x => x.PurchaseMethod == PackagePurchaseMethod.DayaLoan),
DayaAmount = ordered.Where(x => x.PurchaseMethod == PackagePurchaseMethod.DayaLoan).Sum(x => x.Amount),
ManualCount = ordered.Count(x => x.PurchaseMethod == PackagePurchaseMethod.Manual),
ManualAmount = ordered.Where(x => x.PurchaseMethod == PackagePurchaseMethod.Manual).Sum(x => x.Amount),
GatewayCount = ordered.Count(x => x.PurchaseMethod == PackagePurchaseMethod.DirectPurchase),
GatewayAmount = ordered.Where(x => x.PurchaseMethod == PackagePurchaseMethod.DirectPurchase).Sum(x => x.Amount)
};
})
.OrderByDescending(x => x.LastPurchasedAt)
.ToList();
var totalCount = grouped.Count;
var pageNumber = exportAll ? 1 : pagination.PageNumber;
var pageSize = exportAll ? totalCount : pagination.PageSize;
var totalPage = exportAll || pageSize <= 0
? (totalCount > 0 ? 1 : 0)
: (int)Math.Ceiling(totalCount / (double)pageSize);
var pageModels = exportAll
? grouped
: grouped
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();
return new GetCustomerPackagePurchaseRollupResponseDto
{
MetaData = new MetaData
{
CurrentPage = pageNumber,
PageSize = pageSize,
TotalCount = totalCount,
TotalPage = totalPage,
HasPrevious = !exportAll && pageNumber > 1,
HasNext = !exportAll && pageNumber < totalPage
},
Models = pageModels
};
}
}
@@ -0,0 +1,19 @@
using MediatR;
namespace CMSMicroservice.Application.WalletCQ.Commands.AdminManualCreditCharge;
public class AdminManualCreditChargeCommand : IRequest<AdminManualCreditChargeResult>
{
public long UserId { get; set; }
public long Amount { get; set; }
public string? Note { get; set; }
public string? PerformedByAdminId { get; set; }
}
public class AdminManualCreditChargeResult
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
public long TransactionId { get; set; }
public long NewBalance { get; set; }
}
@@ -0,0 +1,105 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.WalletCQ.Commands.AdminManualCreditCharge;
public class AdminManualCreditChargeCommandHandler
: IRequestHandler<AdminManualCreditChargeCommand, AdminManualCreditChargeResult>
{
private readonly IApplicationDbContext _context;
private readonly ILogger<AdminManualCreditChargeCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public AdminManualCreditChargeCommandHandler(
IApplicationDbContext context,
ILogger<AdminManualCreditChargeCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_logger = logger;
_paymentLock = paymentLock;
}
public Task<AdminManualCreditChargeResult> Handle(
AdminManualCreditChargeCommand request,
CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<AdminManualCreditChargeResult> HandleCore(
AdminManualCreditChargeCommand request,
CancellationToken cancellationToken)
{
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken)
?? throw new NotFoundException(nameof(User), request.UserId);
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken)
?? throw new NotFoundException($"کیف پول کاربر با شناسه {request.UserId} یافت نشد");
var oldBalance = wallet.Balance;
wallet.Balance += request.Amount;
var refId = $"MANUAL-{Guid.NewGuid():N}";
var notePart = string.IsNullOrWhiteSpace(request.Note)
? string.Empty
: $" — {request.Note.Trim()}";
var adminPart = string.IsNullOrWhiteSpace(request.PerformedByAdminId)
? string.Empty
: $" (ادمین: {request.PerformedByAdminId})";
var transaction = new Transaction
{
Amount = request.Amount,
Description = $"شارژ دستی کیف پول اصلی - کاربر {user.Id}{notePart}{adminPart}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
RefId = refId,
Type = TransactionType.ManualCreditWalletCharge
};
_context.Transactions.Add(transaction);
await _context.SaveChangesAsync(cancellationToken);
_context.UserWalletHistories.Add(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = request.Amount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = 0,
IsIncrease = true,
RefrenceId = transaction.Id
});
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Manual credit charge: UserId={UserId}, Amount={Amount}, OldBalance={Old}, NewBalance={New}, TxId={TxId}, Admin={Admin}",
user.Id,
request.Amount,
oldBalance,
wallet.Balance,
transaction.Id,
request.PerformedByAdminId);
return new AdminManualCreditChargeResult
{
Success = true,
Message = "شارژ دستی کیف پول اصلی با موفقیت انجام شد",
TransactionId = transaction.Id,
NewBalance = wallet.Balance
};
}
}
@@ -0,0 +1,20 @@
using CMSMicroservice.Domain.Common;
using FluentValidation;
namespace CMSMicroservice.Application.WalletCQ.Commands.AdminManualCreditCharge;
public class AdminManualCreditChargeCommandValidator : AbstractValidator<AdminManualCreditChargeCommand>
{
public AdminManualCreditChargeCommandValidator()
{
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
RuleFor(x => x.Amount)
.GreaterThanOrEqualTo(SystemConstants.DiscountWalletMinCharge)
.WithMessage("حداقل مبلغ شارژ ۱۰,۰۰۰ تومان است")
.LessThanOrEqualTo(SystemConstants.WalletMaxSafeAmount)
.WithMessage("مبلغ وارد شده بیش از حد مجاز است");
}
}
@@ -55,9 +55,16 @@ public class ChargeCreditWalletCommandHandler
request.Amount); request.Amount);
var user = await _context.Users var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken) .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken)
?? throw new NotFoundException(nameof(User), request.UserId); ?? throw new NotFoundException(nameof(User), request.UserId);
if (user.ClubMembership?.IsActive != true)
{
throw new BadRequestException(
"برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.");
}
var wallet = await _context.UserWallets var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken) .FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken)
?? throw new NotFoundException("کیف پول کاربر یافت نشد"); ?? throw new NotFoundException("کیف پول کاربر یافت نشد");
@@ -51,9 +51,16 @@ public class VerifyCreditWalletChargeCommandHandler
request.Authority); request.Authority);
var user = await _context.Users var user = await _context.Users
.Include(u => u.ClubMembership)
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken) .FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken)
?? throw new NotFoundException(nameof(User), request.UserId); ?? throw new NotFoundException(nameof(User), request.UserId);
if (user.ClubMembership?.IsActive != true)
{
throw new BadRequestException(
"برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.");
}
var paymentTx = await _context.PaymentTransactions var paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken); .FirstOrDefaultAsync(pt => pt.Authority == request.Authority, cancellationToken);
@@ -0,0 +1,39 @@
using CMSMicroservice.Application.Common.Models;
using MediatR;
namespace CMSMicroservice.Application.WalletCQ.Queries.GetManualCreditCharges;
public class GetManualCreditChargesQuery : IRequest<GetManualCreditChargesResponseDto>
{
public PaginationState? PaginationState { get; set; }
public string? SortBy { get; set; }
public GetManualCreditChargesFilter? Filter { get; set; }
}
public class GetManualCreditChargesFilter
{
public long? UserId { get; set; }
public long? TransactionId { get; set; }
public string? RefId { get; set; }
public DateTime? CreatedFrom { get; set; }
public DateTime? CreatedTo { get; set; }
}
public class GetManualCreditChargesResponseDto
{
public MetaData MetaData { get; set; } = new();
public List<ManualCreditChargeModelDto> Models { get; set; } = new();
}
public class ManualCreditChargeModelDto
{
public long TransactionId { get; set; }
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public long Amount { get; set; }
public string Description { get; set; } = string.Empty;
public string? RefId { get; set; }
public long NewBalance { get; set; }
public DateTime Created { get; set; }
public DateTime? PaymentDate { get; set; }
}
@@ -0,0 +1,69 @@
using CMSMicroservice.Application.Common.Extensions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.WalletCQ.Queries.GetManualCreditCharges;
public class GetManualCreditChargesQueryHandler
: IRequestHandler<GetManualCreditChargesQuery, GetManualCreditChargesResponseDto>
{
private readonly IApplicationDbContext _context;
public GetManualCreditChargesQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetManualCreditChargesResponseDto> Handle(
GetManualCreditChargesQuery request,
CancellationToken cancellationToken)
{
var filter = request.Filter;
var query =
from t in _context.Transactions.AsNoTracking()
where t.Type == TransactionType.ManualCreditWalletCharge
join h in _context.UserWalletHistories.AsNoTracking() on t.Id equals h.RefrenceId
join w in _context.UserWallets.AsNoTracking() on h.WalletId equals w.Id
join u in _context.Users.AsNoTracking() on w.UserId equals u.Id
select new ManualCreditChargeModelDto
{
TransactionId = t.Id,
UserId = w.UserId,
UserName = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim(),
Amount = t.Amount,
Description = t.Description ?? string.Empty,
RefId = t.RefId,
NewBalance = h.CurrentBalance,
Created = t.Created,
PaymentDate = t.PaymentDate
};
if (filter?.UserId is long userId)
query = query.Where(x => x.UserId == userId);
if (filter?.TransactionId is long txId)
query = query.Where(x => x.TransactionId == txId);
if (!string.IsNullOrWhiteSpace(filter?.RefId))
query = query.Where(x => x.RefId != null && x.RefId.Contains(filter.RefId));
if (filter?.CreatedFrom is DateTime from)
query = query.Where(x => x.Created >= from);
if (filter?.CreatedTo is DateTime to)
query = query.Where(x => x.Created <= to);
query = query.OrderByDescending(x => x.Created);
return new GetManualCreditChargesResponseDto
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.ToListAsync(cancellationToken)
};
}
}
@@ -0,0 +1,18 @@
namespace CMSMicroservice.Domain.Entities;
/// <summary>
/// رابطه دعوت‌کننده ↔ دعوت‌شونده (جدا از معرف شبکه / NetworkParent).
/// کد دعوت = شماره موبایل دعوت‌کننده.
/// </summary>
public class UserInvitation : BaseAuditableEntity
{
/// <summary>کاربری که دعوت کرده</summary>
public long InviterUserId { get; set; }
public virtual User Inviter { get; set; } = null!;
/// <summary>کاربری که دعوت شده (حداکثر یک دعوت‌کننده)</summary>
public long InviteeUserId { get; set; }
public virtual User Invitee { get; set; } = null!;
}
@@ -15,5 +15,7 @@ public enum DeliveryStatus
Returned = 4, Returned = 4,
// لغو شده // لغو شده
Cancelled = 5, Cancelled = 5,
// آماده تحویل حضوری در دفتر
ReadyForOfficePickup = 6,
} }
@@ -18,5 +18,10 @@ public enum PackagePurchaseMethod
/// <summary> /// <summary>
/// از طریق پرداخت مستقیم درگاه بانکی /// از طریق پرداخت مستقیم درگاه بانکی
/// </summary> /// </summary>
DirectPurchase = 2 DirectPurchase = 2,
/// <summary>
/// ثبت دستی توسط ادمین
/// </summary>
Manual = 3
} }
@@ -41,4 +41,9 @@ public enum TransactionType
/// شارژ کیف پول اصلی از درگاه /// شارژ کیف پول اصلی از درگاه
/// </summary> /// </summary>
CreditWalletCharge = 16, CreditWalletCharge = 16,
/// <summary>
/// شارژ دستی کیف پول اصلی توسط ادمین
/// </summary>
ManualCreditWalletCharge = 17,
} }
@@ -77,27 +77,33 @@ public static class ConfigureServices
}); });
} }
// Payment Gateway Service - Multi-Provider Architecture // Payment Gateway — default provider + multi-gateway resolver (ZarinPal + TwentyCard coexist)
// پشتیبانی از درگاه‌های مختلف: ZarinPal, Daya, Mock
var paymentProvider = configuration.GetValue<string>("PaymentProvider", "Mock")?.ToLowerInvariant(); var paymentProvider = configuration.GetValue<string>("PaymentProvider", "Mock")?.ToLowerInvariant();
services.AddHttpClient<ZarinPalPaymentService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
services.AddHttpClient<TwentyCardPaymentService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
switch (paymentProvider) switch (paymentProvider)
{ {
case "zarinpal": case "zarinpal":
services.AddHttpClient<IPaymentGatewayService, ZarinPalPaymentService>() services.AddScoped<IPaymentGatewayService>(sp => sp.GetRequiredService<ZarinPalPaymentService>());
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
break; break;
case "daya": case "daya":
services.AddHttpClient<IPaymentGatewayService, DayaPaymentService>() services.AddHttpClient<DayaPaymentService>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5)); .SetHandlerLifetime(TimeSpan.FromMinutes(5));
services.AddScoped<IPaymentGatewayService>(sp => sp.GetRequiredService<DayaPaymentService>());
break; break;
case "mock": case "mock":
default: default:
services.AddScoped<IPaymentGatewayService, MockPaymentGatewayService>(); services.AddScoped<IPaymentGatewayService, MockPaymentGatewayService>();
break; break;
} }
services.AddScoped<IPaymentGatewayResolver, PaymentGatewayResolver>();
services.AddScoped<IApplicationDbContext>(p => p.GetRequiredService<ApplicationDbContext>()); services.AddScoped<IApplicationDbContext>(p => p.GetRequiredService<ApplicationDbContext>());
@@ -86,6 +86,7 @@ public class ApplicationDbContext : DbContext, IApplicationDbContext
public DbSet<Product> Products => Set<Product>(); public DbSet<Product> Products => Set<Product>();
public DbSet<ProductImage> ProductImages => Set<ProductImage>(); public DbSet<ProductImage> ProductImages => Set<ProductImage>();
public DbSet<User> Users => Set<User>(); public DbSet<User> Users => Set<User>();
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
public DbSet<OtpToken> OtpTokens => Set<OtpToken>(); public DbSet<OtpToken> OtpTokens => Set<OtpToken>();
public DbSet<Contract> Contracts => Set<Contract>(); public DbSet<Contract> Contracts => Set<Contract>();
public DbSet<UserContract> UserContracts => Set<UserContract>(); public DbSet<UserContract> UserContracts => Set<UserContract>();
@@ -13,7 +13,7 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
builder.Property(entity => entity.Id).UseIdentityColumn(); builder.Property(entity => entity.Id).UseIdentityColumn();
builder.Property(entity => entity.FirstName).IsRequired(false); builder.Property(entity => entity.FirstName).IsRequired(false);
builder.Property(entity => entity.LastName).IsRequired(false); builder.Property(entity => entity.LastName).IsRequired(false);
builder.Property(entity => entity.Mobile).IsRequired(true); builder.Property(entity => entity.Mobile).IsRequired(true).HasMaxLength(20);
builder.Property(entity => entity.NationalCode).IsRequired(false); builder.Property(entity => entity.NationalCode).IsRequired(false);
builder.Property(entity => entity.AvatarPath).IsRequired(false); builder.Property(entity => entity.AvatarPath).IsRequired(false);
builder.Property(entity => entity.ReferralCode).IsRequired(true); builder.Property(entity => entity.ReferralCode).IsRequired(true);
@@ -48,5 +48,10 @@ public class UserConfiguration : IEntityTypeConfiguration<User>
builder.HasIndex(e => e.LegPosition) builder.HasIndex(e => e.LegPosition)
.HasDatabaseName("IX_User_LegPosition"); .HasDatabaseName("IX_User_LegPosition");
// موبایل یکتا برای کاربران فعال (lookup دعوت‌کننده)
builder.HasIndex(e => e.Mobile)
.IsUnique()
.HasFilter("[IsDeleted] = 0")
.HasDatabaseName("IX_User_Mobile_Active");
} }
} }
@@ -0,0 +1,38 @@
using CMSMicroservice.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace CMSMicroservice.Infrastructure.Persistence.Configurations;
public class UserInvitationConfiguration : IEntityTypeConfiguration<UserInvitation>
{
public void Configure(EntityTypeBuilder<UserInvitation> builder)
{
builder.ToTable("UserInvitations");
builder.HasQueryFilter(p => !p.IsDeleted);
builder.Ignore(entity => entity.DomainEvents);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).UseIdentityColumn();
builder.Property(entity => entity.InviterUserId).IsRequired();
builder.Property(entity => entity.InviteeUserId).IsRequired();
builder.HasOne(entity => entity.Inviter)
.WithMany()
.HasForeignKey(entity => entity.InviterUserId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(entity => entity.Invitee)
.WithMany()
.HasForeignKey(entity => entity.InviteeUserId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(e => e.InviteeUserId)
.IsUnique()
.HasDatabaseName("IX_UserInvitations_InviteeUserId");
builder.HasIndex(e => new { e.InviterUserId, e.Created })
.HasDatabaseName("IX_UserInvitations_InviterUserId_Created");
}
}
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CMSMicroservice.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUserInvitations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Mobile",
schema: "CMS",
table: "Users",
type: "nvarchar(20)",
maxLength: 20,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)");
migrationBuilder.CreateTable(
name: "UserInvitations",
schema: "CMS",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
InviterUserId = table.Column<long>(type: "bigint", nullable: false),
InviteeUserId = table.Column<long>(type: "bigint", nullable: false),
Created = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
LastModified = table.Column<DateTime>(type: "datetime2", nullable: true),
LastModifiedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserInvitations", x => x.Id);
table.ForeignKey(
name: "FK_UserInvitations_Users_InviteeUserId",
column: x => x.InviteeUserId,
principalSchema: "CMS",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_UserInvitations_Users_InviterUserId",
column: x => x.InviterUserId,
principalSchema: "CMS",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_User_Mobile_Active",
schema: "CMS",
table: "Users",
column: "Mobile",
unique: true,
filter: "[IsDeleted] = 0");
migrationBuilder.CreateIndex(
name: "IX_UserInvitations_InviteeUserId",
schema: "CMS",
table: "UserInvitations",
column: "InviteeUserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserInvitations_InviterUserId_Created",
schema: "CMS",
table: "UserInvitations",
columns: new[] { "InviterUserId", "Created" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserInvitations",
schema: "CMS");
migrationBuilder.DropIndex(
name: "IX_User_Mobile_Active",
schema: "CMS",
table: "Users");
migrationBuilder.AlterColumn<string>(
name: "Mobile",
schema: "CMS",
table: "Users",
type: "nvarchar(max)",
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(20)",
oldMaxLength: 20);
}
}
}
@@ -3574,7 +3574,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Property<string>("Mobile") b.Property<string>("Mobile")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(450)");
b.Property<DateTime?>("MobileVerifiedAt") b.Property<DateTime?>("MobileVerifiedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -3606,6 +3606,11 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.HasIndex("LegPosition") b.HasIndex("LegPosition")
.HasDatabaseName("IX_User_LegPosition"); .HasDatabaseName("IX_User_LegPosition");
b.HasIndex("Mobile")
.IsUnique()
.HasDatabaseName("IX_User_Mobile_Active")
.HasFilter("[IsDeleted] = 0");
b.HasIndex("NetworkParentId") b.HasIndex("NetworkParentId")
.HasDatabaseName("IX_User_NetworkParentId"); .HasDatabaseName("IX_User_NetworkParentId");
@@ -3750,6 +3755,47 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.ToTable("UserContracts", "CMS"); b.ToTable("UserContracts", "CMS");
}); });
modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserInvitation", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("Created")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<long>("InviteeUserId")
.HasColumnType("bigint");
b.Property<long>("InviterUserId")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastModified")
.HasColumnType("datetime2");
b.Property<string>("LastModifiedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("InviteeUserId")
.IsUnique()
.HasDatabaseName("IX_UserInvitations_InviteeUserId");
b.HasIndex("InviterUserId", "Created")
.HasDatabaseName("IX_UserInvitations_InviterUserId_Created");
b.ToTable("UserInvitations", "CMS");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserOrder", b => modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserOrder", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -4871,6 +4917,25 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserInvitation", b =>
{
b.HasOne("CMSMicroservice.Domain.Entities.User", "Invitee")
.WithMany()
.HasForeignKey("InviteeUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("CMSMicroservice.Domain.Entities.User", "Inviter")
.WithMany()
.HasForeignKey("InviterUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Invitee");
b.Navigation("Inviter");
});
modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserOrder", b => modelBuilder.Entity("CMSMicroservice.Domain.Entities.UserOrder", b =>
{ {
b.HasOne("CMSMicroservice.Domain.Entities.Package", "Package") b.HasOne("CMSMicroservice.Domain.Entities.Package", "Package")
@@ -1,5 +1,6 @@
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities; using CMSMicroservice.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using System.Collections.Generic; using System.Collections.Generic;
@@ -13,10 +14,12 @@ namespace CMSMicroservice.Infrastructure.Services;
public class GenerateJwtTokenService : IGenerateJwtToken public class GenerateJwtTokenService : IGenerateJwtToken
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IApplicationDbContext _context;
public GenerateJwtTokenService(IConfiguration configuration) public GenerateJwtTokenService(IConfiguration configuration, IApplicationDbContext context)
{ {
_configuration = configuration; _configuration = configuration;
_context = context;
} }
public async Task<string> GenerateJwtToken(User user, int? expiryDays = null) public async Task<string> GenerateJwtToken(User user, int? expiryDays = null)
@@ -58,6 +61,10 @@ public class GenerateJwtTokenService : IGenerateJwtToken
claims.Add(new Claim("HasPurchasedPackage", claims.Add(new Claim("HasPurchasedPackage",
(user.PackagePurchaseMethod != PackagePurchaseMethod.None).ToString())); (user.PackagePurchaseMethod != PackagePurchaseMethod.None).ToString()));
var hasAddress = await _context.UserAddresses
.AnyAsync(a => a.UserId == user.Id && !a.IsDeleted);
claims.Add(new Claim("HasAddress", hasAddress.ToString()));
if (user.UserRoles != null && user.UserRoles.Any()) if (user.UserRoles != null && user.UserRoles.Any())
{ {
foreach (var userRole in user.UserRoles) foreach (var userRole in user.UserRoles)
@@ -0,0 +1,35 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Infrastructure.Services.Payment;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace CMSMicroservice.Infrastructure.Services.Payment;
public class PaymentGatewayResolver : IPaymentGatewayResolver
{
private readonly IServiceProvider _serviceProvider;
private readonly IConfiguration _configuration;
public PaymentGatewayResolver(IServiceProvider serviceProvider, IConfiguration configuration)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
}
public IPaymentGatewayService Get(string? provider)
{
var key = Normalize(provider)
?? Normalize(_configuration["PaymentProvider"])
?? "zarinpal";
return key switch
{
"20card" or "twentycard" => _serviceProvider.GetRequiredService<TwentyCardPaymentService>(),
"zarinpal" => _serviceProvider.GetRequiredService<ZarinPalPaymentService>(),
_ => _serviceProvider.GetRequiredService<IPaymentGatewayService>()
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
}
@@ -0,0 +1,306 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Infrastructure.Services.Payment;
/// <summary>
/// درگاه پرداخت بیست‌کارت (20Card IPG Merchant API).
/// مبلغ ورودی به تومان است. درخواست‌ها به‌صورت multipart/form-data ارسال می‌شوند.
/// </summary>
public class TwentyCardPaymentService : IPaymentGatewayService
{
private readonly HttpClient _httpClient;
private readonly ILogger<TwentyCardPaymentService> _logger;
private readonly string _terminalId;
private readonly string _terminalUsername;
private readonly string _terminalPassword;
private readonly string _defaultEmail;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public TwentyCardPaymentService(
HttpClient httpClient,
IConfiguration configuration,
ILogger<TwentyCardPaymentService> logger)
{
_httpClient = httpClient;
_logger = logger;
var baseUrl = configuration["TwentyCard:BaseUrl"]
?? throw new InvalidOperationException("TwentyCard:BaseUrl is not configured.");
_terminalId = configuration["TwentyCard:TerminalId"]
?? throw new InvalidOperationException("TwentyCard:TerminalId is not configured.");
_terminalUsername = configuration["TwentyCard:TerminalUsername"]
?? throw new InvalidOperationException("TwentyCard:TerminalUsername is not configured.");
_terminalPassword = configuration["TwentyCard:TerminalPassword"]
?? throw new InvalidOperationException("TwentyCard:TerminalPassword is not configured.");
_defaultEmail = configuration["TwentyCard:DefaultEmail"] ?? "info@kbs2.ir";
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
if (!_httpClient.DefaultRequestHeaders.Contains("Accept"))
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json");
_logger.LogInformation("TwentyCard payment service initialized. BaseUrl={BaseUrl}", baseUrl);
}
public async Task<PaymentInitiateResult> InitiatePaymentAsync(
PaymentRequest request,
CancellationToken cancellationToken = default)
{
try
{
// ۲۰کارت amount را به ریال می‌گیرد (تومان × ۱۰)
var amountRial = ((long)request.Amount) * 10L;
var amount = amountRial.ToString();
var payerName = string.IsNullOrWhiteSpace(request.Name)
? (string.IsNullOrWhiteSpace(request.Mobile) ? "کاربر" : request.Mobile)
: request.Name.Trim();
var invoice = string.IsNullOrWhiteSpace(request.Invoice)
? request.UserId.ToString()
: request.Invoice;
using var content = BuildForm(new Dictionary<string, string>
{
["terminal_id"] = _terminalId,
["terminal_username"] = _terminalUsername,
["terminal_password"] = _terminalPassword,
["amount"] = amount,
["name"] = payerName,
["mobile"] = request.Mobile ?? string.Empty,
["invoice"] = invoice,
["callback"] = request.CallbackUrl,
["description"] = request.Description ?? string.Empty,
// بدون email درگاه ۲۰کارت به‌جای validation، HTTP 500 برمی‌گرداند
["email"] = ResolveEmail(request.Email)
});
if (!string.IsNullOrWhiteSpace(request.NationalId))
AddFormField(content, "national_id", request.NationalId);
_logger.LogInformation(
"TwentyCard payment request: Amount={AmountToman} Toman ({AmountRial} Rial), User={UserId}, Invoice={Invoice}",
(long)request.Amount, amountRial, request.UserId, invoice);
var response = await _httpClient.PostAsync("api/pgw/merchant/request", content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogError(
"TwentyCard HTTP error on request: StatusCode={StatusCode}, Body={Body}",
(int)response.StatusCode, responseBody);
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = $"خطای ارتباط با درگاه ۲۰کارت (HTTP {(int)response.StatusCode})"
};
}
var result = JsonSerializer.Deserialize<TwentyCardRequestResponse>(responseBody, JsonOptions);
var authority = result?.Data?.Authority;
var redirectUrl = result?.Data?.RedirectUrl;
if (string.IsNullOrWhiteSpace(authority) || string.IsNullOrWhiteSpace(redirectUrl))
{
_logger.LogError("TwentyCard request failed. Body={Body}", responseBody);
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = result?.Message
?? result?.Error?.Message
?? "ایجاد تراکنش ۲۰کارت ناموفق بود"
};
}
return new PaymentInitiateResult
{
IsSuccess = true,
RefId = authority,
GatewayUrl = redirectUrl
};
}
catch (Exception ex)
{
_logger.LogError(ex, "TwentyCard InitiatePayment failed");
return new PaymentInitiateResult
{
IsSuccess = false,
ErrorMessage = "خطا در ارتباط با درگاه ۲۰کارت"
};
}
}
public Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
CancellationToken cancellationToken = default)
=> throw new NotImplementedException("TwentyCard requires VerifyPaymentAsync with amount.");
public async Task<PaymentVerificationResult> VerifyPaymentAsync(
string refId,
string verificationToken,
decimal amountInToman,
CancellationToken cancellationToken = default)
{
try
{
if (!string.Equals(verificationToken, "OK", StringComparison.OrdinalIgnoreCase))
{
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = "پرداخت توسط کاربر لغو شد یا ناموفق بود",
VerificationCode = 0
};
}
using var content = BuildForm(new Dictionary<string, string>
{
["terminal_id"] = _terminalId,
["terminal_username"] = _terminalUsername,
["terminal_password"] = _terminalPassword,
["authority"] = refId,
["amount"] = (((long)amountInToman) * 10L).ToString()
});
var response = await _httpClient.PostAsync("api/pgw/merchant/verify", content, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogInformation(
"TwentyCard verify response: Authority={Authority}, StatusCode={StatusCode}, Body={Body}",
refId, (int)response.StatusCode, responseBody);
var result = JsonSerializer.Deserialize<TwentyCardVerifyResponse>(responseBody, JsonOptions);
var paid = result?.Status?.Paid == true;
var verified = result?.Status?.Verified == true;
var noError = result?.Error != true;
if (response.IsSuccessStatusCode && noError && paid && verified)
{
return new PaymentVerificationResult
{
IsSuccess = true,
RefId = refId,
TrackingCode = refId,
Message = result?.Message ?? "تراکنش با موفقیت تایید شد",
VerificationCode = result?.Code ?? 200
};
}
// Code 103 = already verified — treat as success for idempotency
if (result?.Code == 103)
{
return new PaymentVerificationResult
{
IsSuccess = true,
RefId = refId,
TrackingCode = refId,
Message = result.Message ?? "تراکنش قبلاً تایید شده است",
VerificationCode = 103
};
}
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = result?.Message ?? "تایید تراکنش ۲۰کارت ناموفق بود",
VerificationCode = result?.Code ?? (int)response.StatusCode
};
}
catch (Exception ex)
{
_logger.LogError(ex, "TwentyCard VerifyPayment failed. Authority={Authority}", refId);
return new PaymentVerificationResult
{
IsSuccess = false,
RefId = refId,
Message = "خطا در تایید تراکنش ۲۰کارت"
};
}
}
public Task<PayoutResult> ProcessPayoutAsync(
PayoutRequest request,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new PayoutResult
{
IsSuccess = false,
Message = "واریز از طریق درگاه ۲۰کارت پشتیبانی نمی‌شود"
});
}
private static MultipartFormDataContent BuildForm(Dictionary<string, string> fields)
{
var form = new MultipartFormDataContent();
foreach (var (key, value) in fields)
AddFormField(form, key, value ?? string.Empty);
return form;
}
/// <summary>
/// شبیه curl --form: بدون Content-Type روی هر part ( بعضی بک‌اندهای PHP با charset=utf-8 می‌شکنند).
/// </summary>
private static void AddFormField(MultipartFormDataContent form, string name, string value)
{
var part = new StringContent(value ?? string.Empty);
part.Headers.ContentType = null;
form.Add(part, name);
}
private string ResolveEmail(string? email)
{
if (!string.IsNullOrWhiteSpace(email) && email.Contains('@', StringComparison.Ordinal))
return email.Trim();
return _defaultEmail;
}
private sealed class TwentyCardRequestResponse
{
public string? Message { get; set; }
public TwentyCardRequestData? Data { get; set; }
public TwentyCardErrorBody? Error { get; set; }
}
private sealed class TwentyCardErrorBody
{
public string? Code { get; set; }
public string? Message { get; set; }
}
private sealed class TwentyCardRequestData
{
[JsonPropertyName("authority")]
public string? Authority { get; set; }
[JsonPropertyName("redirectUrl")]
public string? RedirectUrl { get; set; }
}
private sealed class TwentyCardVerifyResponse
{
public bool Error { get; set; }
public int Code { get; set; }
public string? Message { get; set; }
public TwentyCardVerifyStatus? Status { get; set; }
}
private sealed class TwentyCardVerifyStatus
{
public bool Paid { get; set; }
public bool Verified { get; set; }
}
}
@@ -3,7 +3,7 @@
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>0.0.204</Version> <Version>0.0.214</Version>
<DebugType>None</DebugType> <DebugType>None</DebugType>
<DebugSymbols>False</DebugSymbols> <DebugSymbols>False</DebugSymbols>
<GeneratePackageOnBuild>False</GeneratePackageOnBuild> <GeneratePackageOnBuild>False</GeneratePackageOnBuild>
@@ -118,6 +118,8 @@ enum DeliveryStatus
DELIVERY_SHIPPED = 2; DELIVERY_SHIPPED = 2;
DELIVERY_DELIVERED = 3; DELIVERY_DELIVERED = 3;
DELIVERY_CANCELLED = 4; DELIVERY_CANCELLED = 4;
DELIVERY_READY_FOR_OFFICE = 5;
DELIVERY_RETURNED = 6;
} }
// Get Order By Id // Get Order By Id
@@ -209,6 +209,11 @@ message NetworkTreeNodeModel
google.protobuf.BoolValue is_new_activation = 22; // true=اولین فعال‌سازی، false=خرید مجدد، null=بدون Cycle google.protobuf.BoolValue is_new_activation = 22; // true=اولین فعال‌سازی، false=خرید مجدد، null=بدون Cycle
string package_name = 23; // نام پکیج موثر string package_name = 23; // نام پکیج موثر
google.protobuf.Int64Value package_id = 24; // شناسه پکیج google.protobuf.Int64Value package_id = 24; // شناسه پکیج
// تعداد اعضای فعال با پکیج جاری در پای چپ/راست (طلایی و نقره‌ای)
int32 gold_left_leg_total = 25;
int32 gold_right_leg_total = 26;
int32 silver_left_leg_total = 27;
int32 silver_right_leg_total = 28;
} }
// GetHistory Query // GetHistory Query
@@ -310,6 +315,7 @@ message GetMyNetworkTreeResponse
// GetMyNetworkStatistics - for frontend customer display // GetMyNetworkStatistics - for frontend customer display
message GetMyNetworkStatisticsResponse message GetMyNetworkStatisticsResponse
{ {
// اعضای فعال باشگاه: کل = چپ + راست
int32 total_members = 1; int32 total_members = 1;
int32 active_members = 2; int32 active_members = 2;
int32 left_leg_count = 3; int32 left_leg_count = 3;
@@ -323,6 +329,11 @@ message GetMyNetworkStatisticsResponse
string my_network_leg = 11; string my_network_leg = 11;
string my_referral_code = 12; string my_referral_code = 12;
CustomerLastMemberModel last_member = 13; CustomerLastMemberModel last_member = 13;
// تعداد اعضای فعال با پکیج جاری در پای چپ/راست — نوار آمار پایین شجره (D3)
int32 gold_left_leg_total = 14;
int32 gold_right_leg_total = 15;
int32 silver_left_leg_total = 16;
int32 silver_right_leg_total = 17;
} }
message CustomerLastMemberModel message CustomerLastMemberModel
@@ -249,6 +249,8 @@ message CustomerPurchasePackageRequest
int64 package_id = 1; int64 package_id = 1;
PurchaseMethodEnum purchase_method = 2; PurchaseMethodEnum purchase_method = 2;
string callback_url = 3; string callback_url = 3;
// "zarinpal" (default) | "20card"
string gateway_provider = 4;
} }
message CustomerPurchasePackageResponse message CustomerPurchasePackageResponse
@@ -66,6 +66,7 @@ message PaymentTransactionModel
google.protobuf.Int64Value transaction_id = 13; google.protobuf.Int64Value transaction_id = 13;
string description = 14; string description = 14;
google.protobuf.Timestamp created_at = 15; google.protobuf.Timestamp created_at = 15;
string user_full_name = 16;
} }
message GetPaymentTransactionSummaryRequest message GetPaymentTransactionSummaryRequest
@@ -102,6 +102,10 @@ enum DeliveryStatus
DeliveryStatus_Delivered = 3; DeliveryStatus_Delivered = 3;
// مرجوع شده // مرجوع شده
DeliveryStatus_Returned = 4; DeliveryStatus_Returned = 4;
// لغو شده
DeliveryStatus_Cancelled = 5;
// آماده تحویل حضوری در دفتر
DeliveryStatus_ReadyForOfficePickup = 6;
} }
enum TransactionType enum TransactionType
{ {
@@ -116,6 +120,7 @@ enum TransactionType
MagicWalletDeposit = 14; MagicWalletDeposit = 14;
MagicWalletBonus = 15; MagicWalletBonus = 15;
CreditWalletCharge = 16; CreditWalletCharge = 16;
ManualCreditWalletCharge = 17;
} }
enum ContractType enum ContractType
{ {
@@ -192,6 +192,9 @@ message GetAllTransactionsByFilterResponseModel
messages.TransactionType type = 7; messages.TransactionType type = 7;
} }
google.protobuf.Timestamp created = 8; google.protobuf.Timestamp created = 8;
google.protobuf.Int64Value user_id = 9;
string user_full_name = 10;
google.protobuf.StringValue mobile = 11;
} }
message GetTransactionSummaryRequest message GetTransactionSummaryRequest
@@ -115,6 +115,16 @@ service UserContract
}; };
}; };
rpc GetMyInvitees(GetMyInviteesRequest) returns (GetMyInviteesResponse){
option (google.api.http) = {
get: "/Customer/GetMyInvitees"
};
};
rpc GetInvitationsByFilter(GetInvitationsByFilterRequest) returns (GetInvitationsByFilterResponse){
option (google.api.http) = {
get: "/GetInvitationsByFilter"
};
};
rpc UploadCustomerAvatar(UploadCustomerAvatarRequest) returns (UploadCustomerAvatarResponse){ rpc UploadCustomerAvatar(UploadCustomerAvatarRequest) returns (UploadCustomerAvatarResponse){
option (google.api.http) = { option (google.api.http) = {
post: "/Customer/UploadAvatar" post: "/Customer/UploadAvatar"
@@ -238,6 +248,8 @@ message GetAllUserByFilterResponseModel
bool sms_notifications = 12; bool sms_notifications = 12;
bool push_notifications = 13; bool push_notifications = 13;
google.protobuf.Timestamp birth_date = 14; google.protobuf.Timestamp birth_date = 14;
string default_address = 15;
string default_postal_code = 16;
} }
message GetJwtTokenRequest message GetJwtTokenRequest
{ {
@@ -292,6 +304,8 @@ message VerifyOtpTokenRequest
string purpose = 2; string purpose = 2;
string code = 3; string code = 3;
google.protobuf.StringValue parent_referral_code = 4; google.protobuf.StringValue parent_referral_code = 4;
// شماره موبایل دعوت‌کننده (اختیاری؛ فقط برای ثبت‌نام جدید)
google.protobuf.StringValue inviter_mobile = 5;
} }
message VerifyOtpTokenResponse message VerifyOtpTokenResponse
{ {
@@ -464,3 +478,78 @@ message UpdateCustomerSettingsRequest
google.protobuf.StringValue time_zone = 6; google.protobuf.StringValue time_zone = 6;
google.protobuf.BoolValue two_factor_auth_enabled = 7; google.protobuf.BoolValue two_factor_auth_enabled = 7;
} }
// ============= Inviter (دعوت‌کننده) — جدا از معرف شبکه =============
message GetMyInviteesRequest
{
city.PaginationState pagination_state = 1;
// ALL | ACTIVE | INACTIVE — بر اساس ClubMembership.IsActive
google.protobuf.StringValue club_status_filter = 2;
}
message GetMyInviteesResponse
{
city.MetaData meta_data = 1;
repeated InviteeModel invitees = 2;
InviteeStats stats = 3;
}
message InviteeModel
{
int64 user_id = 1;
google.protobuf.StringValue first_name = 2;
google.protobuf.StringValue last_name = 3;
string mobile = 4;
google.protobuf.Timestamp registered_at = 5;
bool is_club_active = 6;
}
message InviteeStats
{
int32 total_invitees = 1;
int32 club_active_count = 2;
int32 club_inactive_count = 3;
}
message GetInvitationsByFilterRequest
{
city.PaginationState pagination_state = 1;
google.protobuf.StringValue sort_by = 2;
GetInvitationsByFilterFilter filter = 3;
}
message GetInvitationsByFilterFilter
{
google.protobuf.StringValue inviter_mobile = 1;
google.protobuf.StringValue inviter_first_name = 2;
google.protobuf.StringValue inviter_last_name = 3;
google.protobuf.StringValue invitee_mobile = 4;
google.protobuf.StringValue invitee_first_name = 5;
google.protobuf.StringValue invitee_last_name = 6;
google.protobuf.BoolValue invitee_club_active = 7;
google.protobuf.Timestamp created_from = 8;
google.protobuf.Timestamp created_to = 9;
}
message GetInvitationsByFilterResponse
{
city.MetaData meta_data = 1;
repeated InvitationReportModel models = 2;
}
message InvitationReportModel
{
int64 id = 1;
int64 inviter_user_id = 2;
google.protobuf.StringValue inviter_first_name = 3;
google.protobuf.StringValue inviter_last_name = 4;
string inviter_mobile = 5;
int64 invitee_user_id = 6;
google.protobuf.StringValue invitee_first_name = 7;
google.protobuf.StringValue invitee_last_name = 8;
string invitee_mobile = 9;
google.protobuf.Timestamp invitee_registered_at = 10;
bool invitee_club_active = 11;
google.protobuf.Timestamp invited_at = 12;
}
@@ -220,6 +220,9 @@ message GetUserOrderResponse
google.protobuf.StringValue user_national_code = 16; google.protobuf.StringValue user_national_code = 16;
// اطلاعات مالیات بر ارزش افزوده // اطلاعات مالیات بر ارزش افزوده
OrderVATInfo vat_info = 17; OrderVATInfo vat_info = 17;
// اطلاعات پستی برای برچسب کارتن
google.protobuf.StringValue postal_code = 18;
google.protobuf.StringValue user_mobile = 19;
} }
// اطلاعات مالیات بر ارزش افزوده // اطلاعات مالیات بر ارزش افزوده
@@ -21,6 +21,11 @@ service UserPackagePurchaseContract
get: "/UserPackagePurchase/GetSummary" get: "/UserPackagePurchase/GetSummary"
}; };
}; };
rpc GetCustomerPackagePurchaseRollup(GetCustomerPackagePurchaseRollupRequest) returns (GetCustomerPackagePurchaseRollupResponse){
option (google.api.http) = {
get: "/UserPackagePurchase/GetCustomerRollup"
};
};
} }
message GetAllUserPackagePurchaseByFilterRequest message GetAllUserPackagePurchaseByFilterRequest
@@ -73,3 +78,51 @@ message GetUserPackagePurchaseSummaryResponse
int64 total_count = 1; int64 total_count = 1;
int64 total_amount = 2; int64 total_amount = 2;
} }
message GetCustomerPackagePurchaseRollupRequest
{
messages.PaginationState pagination_state = 1;
GetCustomerPackagePurchaseRollupFilter filter = 2;
}
message GetCustomerPackagePurchaseRollupFilter
{
google.protobuf.Int64Value user_id = 1;
google.protobuf.Int32Value purchase_method = 2;
google.protobuf.Timestamp purchased_from = 3;
google.protobuf.Timestamp purchased_to = 4;
}
message GetCustomerPackagePurchaseRollupResponse
{
messages.MetaData meta_data = 1;
repeated CustomerPackagePurchaseRollupModel models = 2;
}
message CustomerPackagePurchaseRollupModel
{
int64 user_id = 1;
string user_name = 2;
string user_mobile = 3;
int64 first_package_id = 4;
string first_package_name = 5;
int64 first_amount = 6;
google.protobuf.Timestamp first_purchased_at = 7;
int64 last_package_id = 8;
string last_package_name = 9;
int64 last_amount = 10;
google.protobuf.Timestamp last_purchased_at = 11;
int32 last_purchase_method = 12;
int32 purchase_count = 13;
int64 total_amount = 14;
int32 daya_count = 15;
int64 daya_amount = 16;
int32 manual_count = 17;
int64 manual_amount = 18;
int32 gateway_count = 19;
int64 gateway_amount = 20;
}
@@ -125,6 +125,20 @@ service UserWalletContract
body: "*" body: "*"
}; };
}; };
// ============= Admin Manual Credit Charge =============
rpc AdminManualCreditCharge(AdminManualCreditChargeRequest) returns (AdminManualCreditChargeResponse){
option (google.api.http) = {
post: "/Admin/ManualCreditCharge"
body: "*"
};
};
rpc GetManualCreditCharges(GetManualCreditChargesRequest) returns (GetManualCreditChargesResponse){
option (google.api.http) = {
get: "/Admin/GetManualCreditCharges"
};
};
} }
message CreateNewUserWalletRequest message CreateNewUserWalletRequest
{ {
@@ -324,4 +338,56 @@ message VerifyWalletChargeResponse
{ {
bool success = 1; bool success = 1;
string message = 2; string message = 2;
}
// ============= Admin Manual Credit Charge Messages =============
message AdminManualCreditChargeRequest
{
int64 user_id = 1;
int64 amount = 2; // تومان
google.protobuf.StringValue note = 3;
}
message AdminManualCreditChargeResponse
{
bool success = 1;
string message = 2;
int64 transaction_id = 3;
int64 new_balance = 4;
}
message GetManualCreditChargesRequest
{
messages.PaginationState pagination_state = 1;
google.protobuf.StringValue sort_by = 2;
GetManualCreditChargesFilter filter = 3;
}
message GetManualCreditChargesFilter
{
google.protobuf.Int64Value user_id = 1;
google.protobuf.Int64Value transaction_id = 2;
google.protobuf.StringValue ref_id = 3;
google.protobuf.Timestamp created_from = 4;
google.protobuf.Timestamp created_to = 5;
}
message GetManualCreditChargesResponse
{
messages.MetaData meta_data = 1;
repeated ManualCreditChargeModel models = 2;
}
message ManualCreditChargeModel
{
int64 transaction_id = 1;
int64 user_id = 2;
string user_name = 3;
int64 amount = 4;
string description = 5;
string ref_id = 6;
int64 new_balance = 7;
google.protobuf.Timestamp created = 8;
google.protobuf.Timestamp payment_date = 9;
} }
@@ -95,7 +95,11 @@ public class NetworkMembershipProfile : IRegister
IsActivatedInTargetWeek = node.IsActivatedInTargetWeek, IsActivatedInTargetWeek = node.IsActivatedInTargetWeek,
PackageName = node.PackageName ?? string.Empty, PackageName = node.PackageName ?? string.Empty,
PackageId = node.PackageId.HasValue ? node.PackageId.Value : null, PackageId = node.PackageId.HasValue ? node.PackageId.Value : null,
IsNewActivation = node.IsNewActivation.HasValue ? node.IsNewActivation.Value : null IsNewActivation = node.IsNewActivation.HasValue ? node.IsNewActivation.Value : null,
GoldLeftLegTotal = node.GoldLeftLegTotal,
GoldRightLegTotal = node.GoldRightLegTotal,
SilverLeftLegTotal = node.SilverLeftLegTotal,
SilverRightLegTotal = node.SilverRightLegTotal
}; };
if (parentId.HasValue) if (parentId.HasValue)
@@ -46,7 +46,8 @@ public class PaymentTransactionProfile : IRegister
.Map(dest => dest.OrderId, src => src.OrderId ?? string.Empty) .Map(dest => dest.OrderId, src => src.OrderId ?? string.Empty)
.Map(dest => dest.TransactionId, src => src.TransactionId) .Map(dest => dest.TransactionId, src => src.TransactionId)
.Map(dest => dest.Description, src => src.Description ?? string.Empty) .Map(dest => dest.Description, src => src.Description ?? string.Empty)
.Map(dest => dest.CreatedAt, src => Timestamp.FromDateTimeOffset(src.Created)); .Map(dest => dest.CreatedAt, src => Timestamp.FromDateTimeOffset(src.Created))
.Map(dest => dest.UserFullName, src => src.UserFullName ?? string.Empty);
config.NewConfig<ProtoPt.GetPaymentTransactionSummaryRequest, AppPtSummary.GetPaymentTransactionSummaryQuery>() config.NewConfig<ProtoPt.GetPaymentTransactionSummaryRequest, AppPtSummary.GetPaymentTransactionSummaryQuery>()
.Map(dest => dest.Filter, src => src.Filter); .Map(dest => dest.Filter, src => src.Filter);
@@ -40,7 +40,10 @@ public class TransactionsProfile : IRegister
.Map(dest => dest.PaymentDate, src => src.PaymentDate.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(src.PaymentDate.Value, DateTimeKind.Utc)) : null) .Map(dest => dest.PaymentDate, src => src.PaymentDate.HasValue ? Timestamp.FromDateTime(DateTime.SpecifyKind(src.PaymentDate.Value, DateTimeKind.Utc)) : null)
.Map(dest => dest.RefId, src => src.RefId) .Map(dest => dest.RefId, src => src.RefId)
.Map(dest => dest.Type, src => src.Type) .Map(dest => dest.Type, src => src.Type)
.Map(dest => dest.Created, src => Timestamp.FromDateTime(DateTime.SpecifyKind(src.Created, DateTimeKind.Utc))); .Map(dest => dest.Created, src => Timestamp.FromDateTime(DateTime.SpecifyKind(src.Created, DateTimeKind.Utc)))
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.UserFullName, src => src.UserFullName ?? string.Empty)
.Map(dest => dest.Mobile, src => src.Mobile);
config.NewConfig<ProtoTx.GetTransactionSummaryRequest, AppTxSummary.GetTransactionSummaryQuery>() config.NewConfig<ProtoTx.GetTransactionSummaryRequest, AppTxSummary.GetTransactionSummaryQuery>()
.Map(dest => dest.Filter, src => src.Filter); .Map(dest => dest.Filter, src => src.Filter);
@@ -3,6 +3,7 @@ using CMSMicroservice.Domain.Enums;
using Mapster; using Mapster;
using AppUp = CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetAllUserPackagePurchaseByFilter; using AppUp = CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetAllUserPackagePurchaseByFilter;
using AppUpSummary = CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetUserPackagePurchaseSummary; using AppUpSummary = CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetUserPackagePurchaseSummary;
using AppRollup = CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetCustomerPackagePurchaseRollup;
using ProtoUp = CMSMicroservice.Protobuf.Protos.UserPackagePurchase; using ProtoUp = CMSMicroservice.Protobuf.Protos.UserPackagePurchase;
namespace CMSMicroservice.WebApi.Common.Mappings; namespace CMSMicroservice.WebApi.Common.Mappings;
@@ -49,5 +50,41 @@ public class UserPackagePurchaseProfile : IRegister
config.NewConfig<AppUpSummary.GetUserPackagePurchaseSummaryResponseDto, ProtoUp.GetUserPackagePurchaseSummaryResponse>() config.NewConfig<AppUpSummary.GetUserPackagePurchaseSummaryResponseDto, ProtoUp.GetUserPackagePurchaseSummaryResponse>()
.Map(dest => dest.TotalCount, src => src.TotalCount) .Map(dest => dest.TotalCount, src => src.TotalCount)
.Map(dest => dest.TotalAmount, src => src.TotalAmount); .Map(dest => dest.TotalAmount, src => src.TotalAmount);
config.NewConfig<ProtoUp.GetCustomerPackagePurchaseRollupRequest, AppRollup.GetCustomerPackagePurchaseRollupQuery>()
.Map(dest => dest.PaginationState, src => src.PaginationState)
.Map(dest => dest.Filter, src => src.Filter);
config.NewConfig<ProtoUp.GetCustomerPackagePurchaseRollupFilter, AppRollup.GetCustomerPackagePurchaseRollupFilter>()
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.PurchaseMethod, src => src.PurchaseMethod == null ? null : (PackagePurchaseMethod?)src.PurchaseMethod)
.Map(dest => dest.PurchasedFrom, src => src.PurchasedFrom != null ? src.PurchasedFrom.ToDateTime() : (DateTime?)null)
.Map(dest => dest.PurchasedTo, src => src.PurchasedTo != null ? src.PurchasedTo.ToDateTime() : (DateTime?)null);
config.NewConfig<AppRollup.GetCustomerPackagePurchaseRollupResponseDto, ProtoUp.GetCustomerPackagePurchaseRollupResponse>()
.Map(dest => dest.MetaData, src => src.MetaData)
.Map(dest => dest.Models, src => src.Models);
config.NewConfig<AppRollup.CustomerPackagePurchaseRollupModel, ProtoUp.CustomerPackagePurchaseRollupModel>()
.Map(dest => dest.UserId, src => src.UserId)
.Map(dest => dest.UserName, src => src.UserName)
.Map(dest => dest.UserMobile, src => src.UserMobile)
.Map(dest => dest.FirstPackageId, src => src.FirstPackageId)
.Map(dest => dest.FirstPackageName, src => src.FirstPackageName)
.Map(dest => dest.FirstAmount, src => src.FirstAmount)
.Map(dest => dest.FirstPurchasedAt, src => Timestamp.FromDateTime(DateTime.SpecifyKind(src.FirstPurchasedAt, DateTimeKind.Utc)))
.Map(dest => dest.LastPackageId, src => src.LastPackageId)
.Map(dest => dest.LastPackageName, src => src.LastPackageName)
.Map(dest => dest.LastAmount, src => src.LastAmount)
.Map(dest => dest.LastPurchasedAt, src => Timestamp.FromDateTime(DateTime.SpecifyKind(src.LastPurchasedAt, DateTimeKind.Utc)))
.Map(dest => dest.LastPurchaseMethod, src => (int)src.LastPurchaseMethod)
.Map(dest => dest.PurchaseCount, src => src.PurchaseCount)
.Map(dest => dest.TotalAmount, src => src.TotalAmount)
.Map(dest => dest.DayaCount, src => src.DayaCount)
.Map(dest => dest.DayaAmount, src => src.DayaAmount)
.Map(dest => dest.ManualCount, src => src.ManualCount)
.Map(dest => dest.ManualAmount, src => src.ManualAmount)
.Map(dest => dest.GatewayCount, src => src.GatewayCount)
.Map(dest => dest.GatewayAmount, src => src.GatewayAmount);
} }
} }
@@ -4,7 +4,10 @@ public class UserProfile : IRegister
{ {
void IRegister.Register(TypeAdapterConfig config) void IRegister.Register(TypeAdapterConfig config)
{ {
//config.NewConfig<Source,Destination>() config.NewConfig<Application.UserCQ.Queries.GetAllUserByFilter.GetAllUserByFilterResponseModel,
// .Map(dest => dest.FullName, src => $"{src.Firstname} {src.Lastname}"); CMSMicroservice.Protobuf.Protos.User.GetAllUserByFilterResponseModel>()
.Map(dest => dest.ParentId, src => src.NetworkParentId)
.Map(dest => dest.DefaultAddress, src => src.DefaultAddress ?? string.Empty)
.Map(dest => dest.DefaultPostalCode, src => src.DefaultPostalCode ?? string.Empty);
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM 194.5.195.53:32082/dotnet/aspnet:9.0 AS base FROM 185.252.31.44:32082/dotnet/aspnet:9.0 AS base
WORKDIR /app WORKDIR /app
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
FROM 194.5.195.53:32082/dotnet/sdk:9.0 AS build FROM 185.252.31.44:32082/dotnet/sdk:9.0 AS build
WORKDIR /src WORKDIR /src
COPY ["CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj", "CMSMicroservice.WebApi/"] COPY ["CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj", "CMSMicroservice.WebApi/"]
COPY ["CMSMicroservice.Application/CMSMicroservice.Application.csproj", "CMSMicroservice.Application/"] COPY ["CMSMicroservice.Application/CMSMicroservice.Application.csproj", "CMSMicroservice.Application/"]
@@ -99,7 +99,20 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
public override async Task<UpdateOrderStatusResponse> UpdateOrderStatus(UpdateOrderStatusRequest request, ServerCallContext context) public override async Task<UpdateOrderStatusResponse> UpdateOrderStatus(UpdateOrderStatusRequest request, ServerCallContext context)
{ {
return await _dispatchRequestToCQRS.Handle<UpdateOrderStatusRequest, UpdateOrderStatusCommand, UpdateOrderStatusResponse>(request, context); // Proto DeliveryStatus ≠ Domain DeliveryStatus — نگاشت صریح لازم است
var command = new UpdateOrderStatusCommand
{
OrderId = request.OrderId,
DeliveryStatus = MapProtoDeliveryToDomain(request.DeliveryStatus),
TrackingCode = request.TrackingCode,
AdminNotes = request.AdminNotes
};
var result = await _sender.Send(command, context.CancellationToken);
return new UpdateOrderStatusResponse
{
Success = result.Success,
Message = result.Message ?? string.Empty
};
} }
public override async Task<GetOrderByIdResponse> GetOrderById(GetOrderByIdRequest request, ServerCallContext context) public override async Task<GetOrderByIdResponse> GetOrderById(GetOrderByIdRequest request, ServerCallContext context)
@@ -141,6 +154,8 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
Address = result.Address.Address ?? "", Address = result.Address.Address ?? "",
PostalCode = result.Address.PostalCode ?? "" PostalCode = result.Address.PostalCode ?? ""
}; };
if (!string.IsNullOrWhiteSpace(result.Address.Phone))
response.Address.Phone = result.Address.Phone;
} }
foreach (var item in result.Items) foreach (var item in result.Items)
@@ -197,9 +212,98 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
return response; return response;
} }
public override async Task<GetAllDiscountOrdersResponse> GetAllDiscountOrders(GetAllDiscountOrdersRequest request, ServerCallContext context) public override async Task<GetAllDiscountOrdersResponse> GetAllDiscountOrders(
GetAllDiscountOrdersRequest request, ServerCallContext context)
{ {
return await _dispatchRequestToCQRS.Handle<GetAllDiscountOrdersRequest, GetAllDiscountOrdersQuery, GetAllDiscountOrdersResponse>(request, context); // Proto PaymentStatus ≠ Domain PaymentStatus (مقادیر int متفاوت) — Adapt خام باعث باگ لیست/فیلتر می‌شود
if (request.PaymentStatus is 3) // PAYMENT_REFUNDED — در دامنه وجود ندارد
{
return new GetAllDiscountOrdersResponse
{
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{
TotalCount = 0,
PageSize = request.PageSize > 0 ? request.PageSize : 20,
CurrentPage = request.PageNumber > 0 ? request.PageNumber : 1,
TotalPage = 0
}
};
}
var query = new GetAllDiscountOrdersQuery
{
PaginationQuery = new Application.Common.Models.PaginationState
{
PageNumber = request.PageNumber > 0 ? request.PageNumber : 1,
PageSize = request.PageSize > 0 ? request.PageSize : 20
},
UserId = request.UserId,
UserMobile = request.UserMobile,
TrackingCode = request.TrackingCode,
FromDate = request.FromDate?.ToDateTime(),
ToDate = request.ToDate?.ToDateTime(),
MinAmount = request.MinAmount,
MaxAmount = request.MaxAmount
};
if (request.PaymentStatus is int paymentStatus)
query.PaymentStatus = MapProtoPaymentFilterToDomain(paymentStatus);
if (request.DeliveryStatus is int deliveryStatus)
query.DeliveryStatus = MapProtoDeliveryFilterToDomain(deliveryStatus);
var result = await _sender.Send(query, context.CancellationToken);
var response = new GetAllDiscountOrdersResponse
{
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{
TotalCount = result.MetaData.TotalCount,
PageSize = result.MetaData.PageSize,
CurrentPage = result.MetaData.CurrentPage,
TotalPage = result.MetaData.TotalPage,
HasPrevious = result.MetaData.HasPrevious,
HasNext = result.MetaData.HasNext
}
};
foreach (var o in result.Models)
{
var model = new CMSMicroservice.Protobuf.Protos.DiscountOrder.AdminOrderDto
{
Id = o.Id,
UserId = o.UserId,
UserFullName = o.UserFullName ?? string.Empty,
UserMobile = o.UserMobile ?? string.Empty,
TotalAmount = o.TotalAmount,
DiscountBalanceUsed = o.DiscountBalanceUsed,
GatewayAmountPaid = o.GatewayAmountPaid,
VatAmount = o.VatAmount,
PaymentStatus = MapPaymentStatus(o.PaymentStatus),
DeliveryStatus = MapDeliveryStatus(o.DeliveryStatus),
ItemsCount = o.ItemsCount,
Created = Timestamp.FromDateTime(DateTime.SpecifyKind(o.Created, DateTimeKind.Utc))
};
if (o.PaymentDate.HasValue)
model.PaymentDate = Timestamp.FromDateTime(DateTime.SpecifyKind(o.PaymentDate.Value, DateTimeKind.Utc));
if (o.LastModified.HasValue)
model.LastModified = Timestamp.FromDateTime(DateTime.SpecifyKind(o.LastModified.Value, DateTimeKind.Utc));
if (!string.IsNullOrWhiteSpace(o.ShippingAddress))
model.ShippingAddress = o.ShippingAddress;
if (!string.IsNullOrWhiteSpace(o.ReceiverName))
model.ReceiverName = o.ReceiverName;
if (!string.IsNullOrWhiteSpace(o.ReceiverMobile))
model.ReceiverMobile = o.ReceiverMobile;
if (!string.IsNullOrWhiteSpace(o.TrackingCode))
model.TrackingCode = o.TrackingCode;
if (!string.IsNullOrWhiteSpace(o.AdminNote))
model.AdminNote = o.AdminNote;
response.Models.Add(model);
}
return response;
} }
public override async Task<GetDiscountSalesReportResponse> GetDiscountSalesReport(GetDiscountSalesReportRequest request, ServerCallContext context) public override async Task<GetDiscountSalesReportResponse> GetDiscountSalesReport(GetDiscountSalesReportRequest request, ServerCallContext context)
@@ -359,14 +463,50 @@ public class DiscountOrderService : DiscountOrderContract.DiscountOrderContractB
_ => CMSMicroservice.Protobuf.Protos.DiscountOrder.PaymentStatus.PaymentPending _ => CMSMicroservice.Protobuf.Protos.DiscountOrder.PaymentStatus.PaymentPending
}; };
/// <summary>
/// فیلتر UI/proto → دامنه: PENDING=0, COMPLETED=1, FAILED=2
/// </summary>
private static DomainEnums.PaymentStatus MapProtoPaymentFilterToDomain(int protoPaymentStatus) => protoPaymentStatus switch
{
1 => DomainEnums.PaymentStatus.Success, // PAYMENT_COMPLETED
2 => DomainEnums.PaymentStatus.Reject, // PAYMENT_FAILED
_ => DomainEnums.PaymentStatus.Pending // PAYMENT_PENDING (0) و پیش‌فرض
};
private static DeliveryStatus MapDeliveryStatus(DomainEnums.DeliveryStatus status) => status switch private static DeliveryStatus MapDeliveryStatus(DomainEnums.DeliveryStatus status) => status switch
{ {
DomainEnums.DeliveryStatus.None => DeliveryStatus.DeliveryPending, DomainEnums.DeliveryStatus.None => DeliveryStatus.DeliveryPending,
DomainEnums.DeliveryStatus.Pending => DeliveryStatus.DeliveryProcessing, DomainEnums.DeliveryStatus.Pending => DeliveryStatus.DeliveryProcessing,
DomainEnums.DeliveryStatus.InTransit => DeliveryStatus.DeliveryShipped, DomainEnums.DeliveryStatus.InTransit => DeliveryStatus.DeliveryShipped,
DomainEnums.DeliveryStatus.Delivered => DeliveryStatus.DeliveryDelivered, DomainEnums.DeliveryStatus.Delivered => DeliveryStatus.DeliveryDelivered,
DomainEnums.DeliveryStatus.Returned => DeliveryStatus.DeliveryCancelled,
DomainEnums.DeliveryStatus.Cancelled => DeliveryStatus.DeliveryCancelled, DomainEnums.DeliveryStatus.Cancelled => DeliveryStatus.DeliveryCancelled,
DomainEnums.DeliveryStatus.ReadyForOfficePickup => DeliveryStatus.DeliveryReadyForOffice,
DomainEnums.DeliveryStatus.Returned => DeliveryStatus.DeliveryReturned,
_ => DeliveryStatus.DeliveryPending _ => DeliveryStatus.DeliveryPending
}; };
/// <summary>
/// فیلتر UI/proto → دامنه DeliveryStatus
/// </summary>
private static DomainEnums.DeliveryStatus MapProtoDeliveryFilterToDomain(int protoDeliveryStatus) => protoDeliveryStatus switch
{
1 => DomainEnums.DeliveryStatus.Pending, // DELIVERY_PROCESSING
2 => DomainEnums.DeliveryStatus.InTransit, // DELIVERY_SHIPPED
3 => DomainEnums.DeliveryStatus.Delivered, // DELIVERY_DELIVERED
4 => DomainEnums.DeliveryStatus.Cancelled, // DELIVERY_CANCELLED
5 => DomainEnums.DeliveryStatus.ReadyForOfficePickup, // DELIVERY_READY_FOR_OFFICE
6 => DomainEnums.DeliveryStatus.Returned, // DELIVERY_RETURNED
_ => DomainEnums.DeliveryStatus.None // DELIVERY_PENDING (0)
};
private static DomainEnums.DeliveryStatus MapProtoDeliveryToDomain(DeliveryStatus status) => status switch
{
DeliveryStatus.DeliveryProcessing => DomainEnums.DeliveryStatus.Pending,
DeliveryStatus.DeliveryShipped => DomainEnums.DeliveryStatus.InTransit,
DeliveryStatus.DeliveryDelivered => DomainEnums.DeliveryStatus.Delivered,
DeliveryStatus.DeliveryCancelled => DomainEnums.DeliveryStatus.Cancelled,
DeliveryStatus.DeliveryReadyForOffice => DomainEnums.DeliveryStatus.ReadyForOfficePickup,
DeliveryStatus.DeliveryReturned => DomainEnums.DeliveryStatus.Returned,
_ => DomainEnums.DeliveryStatus.None
};
} }
@@ -185,6 +185,11 @@ public class NetworkMembershipService : NetworkMembershipContract.NetworkMembers
node.PackageId = dto.PackageId.Value; node.PackageId = dto.PackageId.Value;
} }
node.GoldLeftLegTotal = dto.GoldLeftLegTotal;
node.GoldRightLegTotal = dto.GoldRightLegTotal;
node.SilverLeftLegTotal = dto.SilverLeftLegTotal;
node.SilverRightLegTotal = dto.SilverRightLegTotal;
if (dto.IsNewActivation.HasValue) if (dto.IsNewActivation.HasValue)
{ {
node.IsNewActivation = dto.IsNewActivation.Value; node.IsNewActivation = dto.IsNewActivation.Value;
@@ -32,7 +32,7 @@ public class PackageService : PackageContract.PackageContractBase
private readonly ISender _sender; private readonly ISender _sender;
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUserService; private readonly ICurrentUserService _currentUserService;
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayResolver _paymentGatewayResolver;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IUserPaymentLock _paymentLock; private readonly IUserPaymentLock _paymentLock;
@@ -41,7 +41,7 @@ public class PackageService : PackageContract.PackageContractBase
ISender sender, ISender sender,
IApplicationDbContext context, IApplicationDbContext context,
ICurrentUserService currentUserService, ICurrentUserService currentUserService,
IPaymentGatewayService paymentGateway, IPaymentGatewayResolver paymentGatewayResolver,
IConfiguration configuration, IConfiguration configuration,
IUserPaymentLock paymentLock) IUserPaymentLock paymentLock)
{ {
@@ -49,7 +49,7 @@ public class PackageService : PackageContract.PackageContractBase
_sender = sender; _sender = sender;
_context = context; _context = context;
_currentUserService = currentUserService; _currentUserService = currentUserService;
_paymentGateway = paymentGateway; _paymentGatewayResolver = paymentGatewayResolver;
_configuration = configuration; _configuration = configuration;
_paymentLock = paymentLock; _paymentLock = paymentLock;
} }
@@ -171,19 +171,38 @@ public class PackageService : PackageContract.PackageContractBase
if (package == null) if (package == null)
throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد")); throw new RpcException(new Status(StatusCode.NotFound, "پکیج مورد نظر یافت نشد"));
// Create transaction var gatewayProvider = NormalizeGatewayProvider(request.GatewayProvider);
var isTwentyCard = gatewayProvider == "20card";
long gatewayAmount = package.Price;
if (isTwentyCard)
{
var packageKey = $"TwentyCard:Packages:{package.Id}";
gatewayAmount = _configuration.GetValue<long?>($"{packageKey}:GatewayAmountToman") ?? 0;
var walletCredit = _configuration.GetValue<long?>($"{packageKey}:WalletCreditToman") ?? 0;
if (gatewayAmount <= 0 || walletCredit <= 0)
{
throw new RpcException(new Status(
StatusCode.FailedPrecondition,
"پرداخت ۲۰کارت برای این پکیج پیکربندی نشده است"));
}
}
// Create transaction — 20card: Amount = gateway charge; zarinpal: Package.Price
var transaction = new CMSMicroservice.Domain.Entities.Transaction var transaction = new CMSMicroservice.Domain.Entities.Transaction
{ {
Amount = package.Price, Amount = gatewayAmount,
Description = $"خرید پکیج {package.Title}", Description = isTwentyCard
? $"خرید پکیج {package.Title} (۲۰کارت)"
: $"خرید پکیج {package.Title}",
PaymentStatus = Domain.Enums.PaymentStatus.Pending, PaymentStatus = Domain.Enums.PaymentStatus.Pending,
Type = Domain.Enums.TransactionType.Buy Type = Domain.Enums.TransactionType.Buy
}; };
_context.Transactions.Add(transaction); _context.Transactions.Add(transaction);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Create purchase record // Create purchase record — UPP.Amount always Package.Price (catalog)
var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway var purchaseMethod = request.PurchaseMethod == PurchaseMethodEnum.PurchaseMethodGateway
? Domain.Enums.PackagePurchaseMethod.DirectPurchase ? Domain.Enums.PackagePurchaseMethod.DirectPurchase
: Domain.Enums.PackagePurchaseMethod.DayaLoan; : Domain.Enums.PackagePurchaseMethod.DayaLoan;
@@ -204,20 +223,32 @@ public class PackageService : PackageContract.PackageContractBase
var user = await _context.Users var user = await _context.Users
.AsNoTracking() .AsNoTracking()
.Where(u => u.Id == userId) .Where(u => u.Id == userId)
.Select(u => new { u.Mobile }) .Select(u => new { u.Mobile, u.FirstName, u.LastName, u.Email, u.NationalCode })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
// Callback URL از config — نه از ورودی کاربر (امنیت) // Callback URL از config — نه از ورودی کاربر (امنیت)
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268"; // ۲۰کارت: CallbackBaseUrl اختیاری (مثلاً استیج → kbs2.ir چون دامنه استیج در ترمینال ثبت نیست)
var callbackUrl = $"{frontOfficeBaseUrl}/profile/payment-callback?orderId={purchase.Id}"; var frontOfficeBaseUrl = isTwentyCard
? (_configuration["TwentyCard:CallbackBaseUrl"]
?? _configuration["FrontOfficeBaseUrl"]
?? "https://localhost:5268")
: (_configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268");
var callbackUrl = $"{frontOfficeBaseUrl.TrimEnd('/')}/profile/payment-callback?orderId={purchase.Id}";
var paymentGateway = _paymentGatewayResolver.Get(gatewayProvider);
var payerName = $"{user?.FirstName} {user?.LastName}".Trim();
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest var paymentResult = await paymentGateway.InitiatePaymentAsync(new PaymentRequest
{ {
Amount = package.Price, Amount = gatewayAmount,
UserId = userId, UserId = userId,
Mobile = user?.Mobile ?? string.Empty, Mobile = user?.Mobile ?? string.Empty,
Name = payerName,
Invoice = purchase.Id.ToString(),
Description = $"خرید پکیج {package.Title}", Description = $"خرید پکیج {package.Title}",
CallbackUrl = callbackUrl CallbackUrl = callbackUrl,
Email = user?.Email,
NationalId = user?.NationalCode
}, cancellationToken); }, cancellationToken);
if (!paymentResult.IsSuccess) if (!paymentResult.IsSuccess)
@@ -235,12 +266,14 @@ public class PackageService : PackageContract.PackageContractBase
// Save RefId // Save RefId
transaction.RefId = paymentResult.RefId; transaction.RefId = paymentResult.RefId;
// ثبت PaymentTransaction // ثبت PaymentTransaction — Amount = gateway amount
var paymentTx = new PaymentTransaction var paymentTx = new PaymentTransaction
{ {
GatewayProvider = _configuration["PaymentProvider"] ?? "zarinpal", GatewayProvider = gatewayProvider,
MerchantId = _configuration["ZarinPal:MerchantId"] ?? "", MerchantId = isTwentyCard
Amount = package.Price, ? (_configuration["TwentyCard:TerminalId"] ?? "")
: (_configuration["ZarinPal:MerchantId"] ?? ""),
Amount = gatewayAmount,
CallbackUrl = callbackUrl, CallbackUrl = callbackUrl,
Description = $"خرید پکیج {package.Title}", Description = $"خرید پکیج {package.Title}",
Mobile = user?.Mobile, Mobile = user?.Mobile,
@@ -264,6 +297,14 @@ public class PackageService : PackageContract.PackageContractBase
Authority = paymentResult.RefId ?? string.Empty Authority = paymentResult.RefId ?? string.Empty
}; };
} }
private static string NormalizeGatewayProvider(string? provider)
{
if (string.IsNullOrWhiteSpace(provider))
return "zarinpal";
var key = provider.Trim().ToLowerInvariant();
return key is "20card" or "twentycard" ? "20card" : "zarinpal";
}
public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase( public override async Task<CustomerVerifyPackagePurchaseResponse> CustomerVerifyPackagePurchase(
CustomerVerifyPackagePurchaseRequest request, CustomerVerifyPackagePurchaseRequest request,
@@ -121,7 +121,9 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
TrackingCode = result.TrackingCode, TrackingCode = result.TrackingCode,
DeliveryDescription = result.DeliveryDescription, DeliveryDescription = result.DeliveryDescription,
UserFullName = result.UserFullName, UserFullName = result.UserFullName,
UserNationalCode = result.UserNationalCode UserNationalCode = result.UserNationalCode,
PostalCode = result.PostalCode,
UserMobile = result.UserMobile
}; };
// VAT Info // VAT Info
@@ -789,7 +791,9 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
TrackingCode = result.TrackingCode, TrackingCode = result.TrackingCode,
DeliveryDescription = result.DeliveryDescription, DeliveryDescription = result.DeliveryDescription,
UserFullName = result.UserFullName, UserFullName = result.UserFullName,
UserNationalCode = result.UserNationalCode UserNationalCode = result.UserNationalCode,
PostalCode = result.PostalCode,
UserMobile = result.UserMobile
}; };
// VAT Info // VAT Info
@@ -1054,6 +1058,7 @@ public class UserOrderService : UserOrderContract.UserOrderContractBase
Domain.Enums.DeliveryStatus.Delivered => "تحویل داده شده", Domain.Enums.DeliveryStatus.Delivered => "تحویل داده شده",
Domain.Enums.DeliveryStatus.Returned => "مرجوع شده", Domain.Enums.DeliveryStatus.Returned => "مرجوع شده",
Domain.Enums.DeliveryStatus.Cancelled => "لغو شده", Domain.Enums.DeliveryStatus.Cancelled => "لغو شده",
Domain.Enums.DeliveryStatus.ReadyForOfficePickup => "آماده تحویل در دفتر",
_ => status.ToString() _ => status.ToString()
}; };
} }
@@ -2,6 +2,7 @@ using CMSMicroservice.Protobuf.Protos.UserPackagePurchase;
using CMSMicroservice.WebApi.Common.Services; using CMSMicroservice.WebApi.Common.Services;
using CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetAllUserPackagePurchaseByFilter; using CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetAllUserPackagePurchaseByFilter;
using CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetUserPackagePurchaseSummary; using CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetUserPackagePurchaseSummary;
using CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetCustomerPackagePurchaseRollup;
namespace CMSMicroservice.WebApi.Services; namespace CMSMicroservice.WebApi.Services;
@@ -18,4 +19,8 @@ public class UserPackagePurchaseService : UserPackagePurchaseContract.UserPackag
public override Task<GetUserPackagePurchaseSummaryResponse> GetUserPackagePurchaseSummary( public override Task<GetUserPackagePurchaseSummaryResponse> GetUserPackagePurchaseSummary(
GetUserPackagePurchaseSummaryRequest request, ServerCallContext context) => GetUserPackagePurchaseSummaryRequest request, ServerCallContext context) =>
_dispatch.Handle<GetUserPackagePurchaseSummaryRequest, GetUserPackagePurchaseSummaryQuery, GetUserPackagePurchaseSummaryResponse>(request, context); _dispatch.Handle<GetUserPackagePurchaseSummaryRequest, GetUserPackagePurchaseSummaryQuery, GetUserPackagePurchaseSummaryResponse>(request, context);
public override Task<GetCustomerPackagePurchaseRollupResponse> GetCustomerPackagePurchaseRollup(
GetCustomerPackagePurchaseRollupRequest request, ServerCallContext context) =>
_dispatch.Handle<GetCustomerPackagePurchaseRollupRequest, GetCustomerPackagePurchaseRollupQuery, GetCustomerPackagePurchaseRollupResponse>(request, context);
} }
@@ -15,8 +15,11 @@ using CMSMicroservice.Application.UserCQ.Commands.VerifyOtpToken;
using CMSMicroservice.Application.UserCQ.Commands.AcceptContract; using CMSMicroservice.Application.UserCQ.Commands.AcceptContract;
using CMSMicroservice.Application.UserCQ.Queries.GetCustomerProfile; using CMSMicroservice.Application.UserCQ.Queries.GetCustomerProfile;
using CMSMicroservice.Application.UserCQ.Queries.GetCustomerReferrals; using CMSMicroservice.Application.UserCQ.Queries.GetCustomerReferrals;
using CMSMicroservice.Application.UserCQ.Queries.GetMyInvitees;
using CMSMicroservice.Application.UserCQ.Queries.GetInvitationsByFilter;
using CMSMicroservice.Application.UserCQ.Queries.GetCustomerSettings; using CMSMicroservice.Application.UserCQ.Queries.GetCustomerSettings;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Authorization;
using Google.Protobuf.WellKnownTypes; using Google.Protobuf.WellKnownTypes;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Grpc.Core; using Grpc.Core;
@@ -312,6 +315,117 @@ public class UserService : UserContract.UserContractBase
return response; return response;
} }
public override async Task<GetMyInviteesResponse> GetMyInvitees(
GetMyInviteesRequest request,
ServerCallContext context)
{
var result = await _sender.Send(new GetMyInviteesQuery
{
UserId = 0,
PaginationState = request.PaginationState?.Adapt<AppModels.PaginationState>(),
ClubStatusFilter = request.ClubStatusFilter
}, context.CancellationToken);
var response = new GetMyInviteesResponse
{
MetaData = new MetaData
{
CurrentPage = result.MetaData.CurrentPage,
TotalPage = result.MetaData.TotalPage,
PageSize = result.MetaData.PageSize,
TotalCount = result.MetaData.TotalCount,
HasPrevious = result.MetaData.HasPrevious,
HasNext = result.MetaData.HasNext
},
Stats = new InviteeStats
{
TotalInvitees = result.Stats.TotalInvitees,
ClubActiveCount = result.Stats.ClubActiveCount,
ClubInactiveCount = result.Stats.ClubInactiveCount
}
};
foreach (var item in result.Invitees)
{
response.Invitees.Add(new InviteeModel
{
UserId = item.UserId,
FirstName = item.FirstName,
LastName = item.LastName,
Mobile = item.Mobile,
RegisteredAt = Timestamp.FromDateTime(DateTime.SpecifyKind(item.RegisteredAt, DateTimeKind.Utc)),
IsClubActive = item.IsClubActive
});
}
return response;
}
[RequiresPermission(PermissionNames.UsersView)]
public override async Task<GetInvitationsByFilterResponse> GetInvitationsByFilter(
GetInvitationsByFilterRequest request,
ServerCallContext context)
{
DateTime? createdFrom = null;
DateTime? createdTo = null;
if (request.Filter?.CreatedFrom != null)
createdFrom = request.Filter.CreatedFrom.ToDateTime();
if (request.Filter?.CreatedTo != null)
createdTo = request.Filter.CreatedTo.ToDateTime();
var result = await _sender.Send(new GetInvitationsByFilterQuery
{
PaginationState = request.PaginationState?.Adapt<AppModels.PaginationState>(),
SortBy = request.SortBy,
Filter = request.Filter == null ? null : new GetInvitationsByFilterFilterDto
{
InviterMobile = request.Filter.InviterMobile,
InviterFirstName = request.Filter.InviterFirstName,
InviterLastName = request.Filter.InviterLastName,
InviteeMobile = request.Filter.InviteeMobile,
InviteeFirstName = request.Filter.InviteeFirstName,
InviteeLastName = request.Filter.InviteeLastName,
InviteeClubActive = request.Filter.InviteeClubActive,
CreatedFrom = createdFrom,
CreatedTo = createdTo
}
}, context.CancellationToken);
var response = new GetInvitationsByFilterResponse
{
MetaData = new MetaData
{
CurrentPage = result.MetaData.CurrentPage,
TotalPage = result.MetaData.TotalPage,
PageSize = result.MetaData.PageSize,
TotalCount = result.MetaData.TotalCount,
HasPrevious = result.MetaData.HasPrevious,
HasNext = result.MetaData.HasNext
}
};
foreach (var m in result.Models)
{
response.Models.Add(new InvitationReportModel
{
Id = m.Id,
InviterUserId = m.InviterUserId,
InviterFirstName = m.InviterFirstName,
InviterLastName = m.InviterLastName,
InviterMobile = m.InviterMobile,
InviteeUserId = m.InviteeUserId,
InviteeFirstName = m.InviteeFirstName,
InviteeLastName = m.InviteeLastName,
InviteeMobile = m.InviteeMobile,
InviteeRegisteredAt = Timestamp.FromDateTime(DateTime.SpecifyKind(m.InviteeRegisteredAt, DateTimeKind.Utc)),
InviteeClubActive = m.InviteeClubActive,
InvitedAt = Timestamp.FromDateTime(DateTime.SpecifyKind(m.InvitedAt, DateTimeKind.Utc))
});
}
return response;
}
public override async Task<UploadCustomerAvatarResponse> UploadCustomerAvatar(UploadCustomerAvatarRequest request, ServerCallContext context) public override async Task<UploadCustomerAvatarResponse> UploadCustomerAvatar(UploadCustomerAvatarRequest request, ServerCallContext context)
{ {
if (request.FileData == null || request.FileData.Length == 0) if (request.FileData == null || request.FileData.Length == 0)
@@ -9,12 +9,16 @@ using CMSMicroservice.Application.WalletCQ.Commands.ChargeCreditWallet;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyMagicWalletCharge; using CMSMicroservice.Application.WalletCQ.Commands.VerifyMagicWalletCharge;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyDiscountWalletCharge; using CMSMicroservice.Application.WalletCQ.Commands.VerifyDiscountWalletCharge;
using CMSMicroservice.Application.WalletCQ.Commands.VerifyCreditWalletCharge; using CMSMicroservice.Application.WalletCQ.Commands.VerifyCreditWalletCharge;
using CMSMicroservice.Application.WalletCQ.Commands.AdminManualCreditCharge;
using CMSMicroservice.Application.WalletCQ.Queries.GetManualCreditCharges;
using AppManualChargeFilter = CMSMicroservice.Application.WalletCQ.Queries.GetManualCreditCharges.GetManualCreditChargesFilter;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetUserWallet; using CMSMicroservice.Application.UserWalletCQ.Queries.GetUserWallet;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetAllUserWalletByFilter; using CMSMicroservice.Application.UserWalletCQ.Queries.GetAllUserWalletByFilter;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWalletHistory; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWalletHistory;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawals;
using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings; using CMSMicroservice.Application.UserWalletCQ.Queries.GetCustomerWithdrawalSettings;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
using CMSMicroservice.Domain.Enums; using CMSMicroservice.Domain.Enums;
@@ -172,7 +176,18 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
// Update payout with withdrawal info // Update payout with withdrawal info
payout.WithdrawalMethod = (Domain.Enums.WithdrawalMethod)request.WithdrawalMethod; payout.WithdrawalMethod = (Domain.Enums.WithdrawalMethod)request.WithdrawalMethod;
payout.IbanNumber = request.IbanNumber; if (payout.WithdrawalMethod == Domain.Enums.WithdrawalMethod.Cash)
{
var normalizedIban = IbanNormalizer.TryNormalize(request.IbanNumber);
if (normalizedIban is null)
throw new RpcException(new Status(StatusCode.InvalidArgument,
"فرمت شماره شبا معتبر نیست. باید IR و ۲۴ رقم باشد."));
payout.IbanNumber = normalizedIban;
}
else
{
payout.IbanNumber = null;
}
payout.Status = Domain.Enums.CommissionPayoutStatus.WithdrawRequested; payout.Status = Domain.Enums.CommissionPayoutStatus.WithdrawRequested;
await _context.SaveChangesAsync(context.CancellationToken); await _context.SaveChangesAsync(context.CancellationToken);
@@ -274,6 +289,14 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
ErrorMessage = ex.Message ErrorMessage = ex.Message
}; };
} }
catch (BadRequestException ex)
{
return new InitiateCreditChargeResponse
{
IsSuccess = false,
ErrorMessage = ex.Message
};
}
} }
// ============= Wallet Verify Methods ============= // ============= Wallet Verify Methods =============
@@ -518,4 +541,116 @@ public class UserWalletService : UserWalletContract.UserWalletContractBase
MinWithdrawalAmount = settings.MinWithdrawalAmount MinWithdrawalAmount = settings.MinWithdrawalAmount
}; };
} }
// ============= Admin Manual Credit Charge =============
public override async Task<AdminManualCreditChargeResponse> AdminManualCreditCharge(
AdminManualCreditChargeRequest request, ServerCallContext context)
{
try
{
var result = await _sender.Send(new AdminManualCreditChargeCommand
{
UserId = request.UserId,
Amount = request.Amount,
Note = request.Note,
PerformedByAdminId = _currentUserService.GetPerformedBy()
}, context.CancellationToken);
return new AdminManualCreditChargeResponse
{
Success = result.Success,
Message = result.Message ?? string.Empty,
TransactionId = result.TransactionId,
NewBalance = result.NewBalance
};
}
catch (PaymentInProgressException ex)
{
return new AdminManualCreditChargeResponse
{
Success = false,
Message = ex.Message
};
}
catch (NotFoundException ex)
{
return new AdminManualCreditChargeResponse
{
Success = false,
Message = ex.Message
};
}
catch (Exception ex)
{
_logger.LogError(ex, "AdminManualCreditCharge failed for UserId={UserId}", request.UserId);
return new AdminManualCreditChargeResponse
{
Success = false,
Message = ex.Message
};
}
}
public override async Task<GetManualCreditChargesResponse> GetManualCreditCharges(
GetManualCreditChargesRequest request, ServerCallContext context)
{
var result = await _sender.Send(new GetManualCreditChargesQuery
{
SortBy = request.SortBy,
PaginationState = request.PaginationState != null
? new Application.Common.Models.PaginationState
{
PageNumber = request.PaginationState.PageNumber,
PageSize = request.PaginationState.PageSize
}
: new Application.Common.Models.PaginationState { PageNumber = 1, PageSize = 20 },
Filter = new AppManualChargeFilter
{
UserId = request.Filter?.UserId,
TransactionId = request.Filter?.TransactionId,
RefId = request.Filter?.RefId,
CreatedFrom = request.Filter?.CreatedFrom?.ToDateTime(),
CreatedTo = request.Filter?.CreatedTo?.ToDateTime()
}
}, context.CancellationToken);
var response = new GetManualCreditChargesResponse
{
MetaData = new CMSMicroservice.Protobuf.Protos.MetaData
{
CurrentPage = result.MetaData.CurrentPage,
TotalPage = result.MetaData.TotalPage,
PageSize = result.MetaData.PageSize,
TotalCount = result.MetaData.TotalCount,
HasPrevious = result.MetaData.HasPrevious,
HasNext = result.MetaData.HasNext
}
};
foreach (var m in result.Models)
{
var model = new ManualCreditChargeModel
{
TransactionId = m.TransactionId,
UserId = m.UserId,
UserName = m.UserName ?? string.Empty,
Amount = m.Amount,
Description = m.Description ?? string.Empty,
RefId = m.RefId ?? string.Empty,
NewBalance = m.NewBalance,
Created = Timestamp.FromDateTime(DateTime.SpecifyKind(m.Created, DateTimeKind.Utc))
};
if (m.PaymentDate.HasValue)
{
model.PaymentDate = Timestamp.FromDateTime(
DateTime.SpecifyKind(m.PaymentDate.Value, DateTimeKind.Utc));
}
response.Models.Add(model);
}
return response;
}
} }
@@ -4,6 +4,24 @@
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8", "MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": false "UseSandbox": false
}, },
"TwentyCard": {
"BaseUrl": "https://gateway.20card.ir/ipg",
"TerminalId": "165674",
"TerminalUsername": "ea5y1biehi",
"TerminalPassword": "SaLamAT@1404!",
"CreditPortalUrl": "https://userpanel.20card.ir/",
"DefaultEmail": "info@kbs2.ir",
"Packages": {
"4": {
"GatewayAmountToman": 9700000,
"WalletCreditToman": 13000000
},
"1": {
"GatewayAmountToman": 97000000,
"WalletCreditToman": 130000000
}
}
},
"CmsBaseUrl": "https://cms.kbs2.ir", "CmsBaseUrl": "https://cms.kbs2.ir",
"FrontOfficeBaseUrl": "https://kbs2.ir", "FrontOfficeBaseUrl": "https://kbs2.ir",
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=", "JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
@@ -0,0 +1,112 @@
{
"PaymentProvider": "zarinpal",
"ZarinPal": {
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": true
},
"TwentyCard": {
"BaseUrl": "https://gateway.20card.ir/ipg",
"TerminalId": "165674",
"TerminalUsername": "ea5y1biehi",
"TerminalPassword": "SaLamAT@1404!",
"CreditPortalUrl": "https://userpanel.20card.ir/",
"CallbackBaseUrl": "https://kbs2.ir",
"DefaultEmail": "info@kbs2.ir",
"Packages": {
"4": {
"GatewayAmountToman": 11200000,
"WalletCreditToman": 12656000
},
"1": {
"GatewayAmountToman": 11200000,
"WalletCreditToman": 12656000
}
}
},
"CmsBaseUrl": "https://cms.se.kbs1.ir",
"FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir",
"FMS": {
"Address": "https://dl.afrino.co"
},
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"DefaultConnection": "Data Source=185.252.31.44,31433; Initial Catalog=Foursat;User ID=sa;Password=87zH26nbqT;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"providerName": "System.Data.SqlClient"
},
"Otp": {
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M=",
"MasterCode": "1122334455"
},
"Monitoring": {
"SentryEnabled": false,
"SentryDsn": "",
"SlackEnabled": false,
"SlackWebhookUrl": "",
"EmailAlertsEnabled": false,
"AdminEmails": [
"admin@example.com"
],
"SmsNotificationsEnabled": false,
"SmsApiKey": "",
"SmsGatewayUrl": ""
},
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUsername": "your-email@gmail.com",
"SmtpPassword": "your-app-password",
"FromEmail": "noreply@foursat.com",
"FromName": "FourSat CMS",
"EnableSsl": true
},
"Sms": {
"Enabled": true,
"Provider": "Kavenegar",
"KavenegarApiKey": "43676A4E786A6C50452F2F6252507939346A5145764B33566B456454374E657A614468706658656D6534593D",
"Sender": "1000001110100"
},
"DayaPayment": {
"BaseUrl": "https://api.daya.ir",
"ApiKey": "YOUR_DAYA_API_KEY"
},
"DayaApi": {
"UseMock": false,
"BaseAddress": "https://Dayadiamond.ir",
"MerchantPermissionKey": "56146364$04sXjethI5WxhItR1Q9xnmFdJzl2BB8Bclsq8dAy7YVSZp3vtt-wP7ivrcCvmKLq",
"CacheDurationMinutes": 20
},
"Chatika": {
"Enabled": true,
"BaseUrl": "https://api.chatika.ir",
"ApiKey": "tIukvL8dnV4cB3yVWcCD9Xyfbj8rBxm5wPt2mLyJCgTsBBoMTWjt6mFEqQwpw-er"
},
"BackgroundJobs": {
"WeeklyCommissionCalculation": {
"Enabled": true,
"CronExpression": "5 0 * * 0"
}
},
"SeedWorkers": {
"MagicWalletCycleSeed": {
"Enabled": true
}
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
},
"Authentication": {
"Authority": "https://ids.domain.com/",
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "https://seq.afrino.co",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
}
}
@@ -4,6 +4,24 @@
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8", "MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": false "UseSandbox": false
}, },
"TwentyCard": {
"BaseUrl": "https://gateway.20card.ir/ipg",
"TerminalId": "165674",
"TerminalUsername": "ea5y1biehi",
"TerminalPassword": "SaLamAT@1404!",
"CreditPortalUrl": "https://userpanel.20card.ir/",
"DefaultEmail": "info@kbs2.ir",
"Packages": {
"4": {
"GatewayAmountToman": 11200000,
"WalletCreditToman": 12656000
},
"1": {
"GatewayAmountToman": 11200000,
"WalletCreditToman": 12656000
}
}
},
"CmsBaseUrl": "https://cms.kbs2.ir", "CmsBaseUrl": "https://cms.kbs2.ir",
"FrontOfficeBaseUrl": "https://kbs2.ir", "FrontOfficeBaseUrl": "https://kbs2.ir",
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=", "JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
+2 -2
View File
@@ -3,8 +3,8 @@
<packageSources> <packageSources>
<clear /> <clear />
<!-- Nexus (hosts FourSat packages + proxies nuget.org) --> <!-- Nexus (hosts FourSat packages + proxies nuget.org) -->
<add key="Nexus" value="http://194.5.195.53:32081/repository/nuget-all/index.json" allowInsecureConnections="true" /> <add key="Nexus" value="http://185.252.31.44:32081/repository/nuget-all/index.json" allowInsecureConnections="true" />
<add key="foursat-hosted" value="http://194.5.195.53:32081/repository/foursat-nuget-hosted/index.json" allowInsecureConnections="true" /> <add key="foursat-hosted" value="http://185.252.31.44:32081/repository/foursat-nuget-hosted/index.json" allowInsecureConnections="true" />
</packageSources> </packageSources>
<packageSourceCredentials> <packageSourceCredentials>
<Nexus> <Nexus>
@@ -0,0 +1,257 @@
/*
One-time cleanup: ClubMemberships without a successful UserPackagePurchases row.
Goal after this script:
every non-deleted ClubMembership user has >= 1 successful UPP
(TransactionId IS NULL OR Transaction.PaymentStatus = 0 / Success)
Priority for missing rows:
1) ManualPayments Approved + successful Transaction -> reuse same TransactionId
PurchaseMethod = Manual (3)
2) else ClubMembershipCycles / membership dates -> create synthetic Success Transaction + UPP
PurchaseMethod priority:
Cycle.PurchaseMethod (if not None)
-> ClubMembership.PurchaseMethod (if not None)
-> Users.PackagePurchaseMethod (if not None)
-> Manual (3) as last resort
Safe to re-run (skips users who already have a successful UPP).
Run against CMS database once, then verify Orphans left = 0.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRAN;
------------------------------------------------------------
-- 1) From ManualPayments (reuse existing Success transaction)
------------------------------------------------------------
;WITH OrphanClubUsers AS (
SELECT m.UserId,
COALESCE(NULLIF(m.LastPackageId, 0), NULLIF(m.FirstPackageId, 0)) AS PackageIdHint
FROM CMS.ClubMemberships m
WHERE m.IsDeleted = 0
AND NOT EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
LEFT JOIN CMS.Transactions t ON t.Id = upp.TransactionId AND t.IsDeleted = 0
WHERE upp.UserId = m.UserId
AND upp.IsDeleted = 0
AND (upp.TransactionId IS NULL OR t.PaymentStatus = 0)
)
),
BestManual AS (
SELECT mp.UserId,
mp.Id AS ManualPaymentId,
mp.Amount,
mp.TransactionId,
COALESCE(mp.ApprovedAt, mp.Created) AS PurchasedAt,
ROW_NUMBER() OVER (
PARTITION BY mp.UserId
ORDER BY COALESCE(mp.ApprovedAt, mp.Created) DESC, mp.Id DESC
) AS rn
FROM CMS.ManualPayments mp
INNER JOIN CMS.Transactions t ON t.Id = mp.TransactionId AND t.IsDeleted = 0 AND t.PaymentStatus = 0
WHERE mp.IsDeleted = 0
AND mp.Status = 1 -- Approved
AND mp.TransactionId IS NOT NULL
),
BasePkg AS (
SELECT TOP (1) Id AS PackageId, Price
FROM CMS.Packages
WHERE IsDeleted = 0 AND IsBasePackage = 1
ORDER BY Id
)
INSERT INTO CMS.UserPackagePurchases
(
UserId, PackageId, PurchaseMethod, PurchasedAt, Amount,
OrderId, TransactionId, Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
SELECT
o.UserId,
COALESCE(o.PackageIdHint, b.PackageId),
3, -- Manual
bm.PurchasedAt,
bm.Amount,
NULL,
bm.TransactionId,
SYSUTCDATETIME(),
NULL,
SYSUTCDATETIME(),
NULL,
0
FROM OrphanClubUsers o
INNER JOIN BestManual bm ON bm.UserId = o.UserId AND bm.rn = 1
CROSS JOIN BasePkg b
WHERE COALESCE(o.PackageIdHint, b.PackageId) IS NOT NULL;
DECLARE @FromManual INT = @@ROWCOUNT;
PRINT CONCAT('Inserted from ManualPayments: ', @FromManual);
------------------------------------------------------------
-- 2) Remaining orphans from Cycle / membership dates
------------------------------------------------------------
;WITH OrphanClubUsers AS (
SELECT
m.UserId,
m.PurchaseMethod,
u.PackagePurchaseMethod AS UserPurchaseMethod,
COALESCE(NULLIF(m.LastPackageId, 0), NULLIF(m.FirstPackageId, 0)) AS MembPackageId,
COALESCE(m.FirstActivationDate, m.LastActivationDate, m.ActivatedAt, m.Created) AS MembPurchasedAt
FROM CMS.ClubMemberships m
INNER JOIN CMS.Users u ON u.Id = m.UserId AND u.IsDeleted = 0
WHERE m.IsDeleted = 0
AND NOT EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
LEFT JOIN CMS.Transactions t ON t.Id = upp.TransactionId AND t.IsDeleted = 0
WHERE upp.UserId = m.UserId
AND upp.IsDeleted = 0
AND (upp.TransactionId IS NULL OR t.PaymentStatus = 0)
)
),
BestCycle AS (
SELECT
c.UserId,
c.PackageId,
c.PackageAmount,
c.PurchaseMethod,
c.PackagePurchasedAt,
ROW_NUMBER() OVER (PARTITION BY c.UserId ORDER BY c.CycleNumber ASC, c.Id ASC) AS rn
FROM CMS.ClubMembershipCycles c
WHERE c.IsDeleted = 0
),
BasePkg AS (
SELECT TOP (1) Id AS PackageId, Price
FROM CMS.Packages
WHERE IsDeleted = 0 AND IsBasePackage = 1
ORDER BY Id
),
ToInsert AS (
SELECT
o.UserId,
COALESCE(NULLIF(bc.PackageId, 0), o.MembPackageId, b.PackageId) AS PackageId,
CASE
WHEN bc.PurchaseMethod IS NOT NULL AND bc.PurchaseMethod <> 0 THEN bc.PurchaseMethod
WHEN o.PurchaseMethod IS NOT NULL AND o.PurchaseMethod <> 0 THEN o.PurchaseMethod
WHEN o.UserPurchaseMethod IS NOT NULL AND o.UserPurchaseMethod <> 0 THEN o.UserPurchaseMethod
ELSE 3 -- Manual (last resort)
END AS PurchaseMethod,
COALESCE(bc.PackagePurchasedAt, o.MembPurchasedAt, SYSUTCDATETIME()) AS PurchasedAt,
CASE
WHEN bc.PackageAmount IS NOT NULL AND bc.PackageAmount > 0 THEN bc.PackageAmount
ELSE COALESCE(p.Price, b.Price, 0)
END AS Amount
FROM OrphanClubUsers o
LEFT JOIN BestCycle bc ON bc.UserId = o.UserId AND bc.rn = 1
CROSS JOIN BasePkg b
LEFT JOIN CMS.Packages p ON p.Id = COALESCE(NULLIF(bc.PackageId, 0), o.MembPackageId, b.PackageId) AND p.IsDeleted = 0
)
SELECT *
INTO #BackfillCandidates
FROM ToInsert
WHERE PackageId IS NOT NULL AND Amount > 0;
DECLARE @Remaining INT = (SELECT COUNT(*) FROM #BackfillCandidates);
PRINT CONCAT('Remaining orphans to synthetic backfill: ', @Remaining);
DECLARE @UserId BIGINT, @PackageId BIGINT, @PurchaseMethod INT, @PurchasedAt DATETIME2, @Amount BIGINT;
DECLARE @TxId BIGINT;
DECLARE cur CURSOR LOCAL FAST_FORWARD FOR
SELECT UserId, PackageId, PurchaseMethod, PurchasedAt, Amount
FROM #BackfillCandidates;
OPEN cur;
FETCH NEXT FROM cur INTO @UserId, @PackageId, @PurchaseMethod, @PurchasedAt, @Amount;
DECLARE @Synthetic INT = 0;
WHILE @@FETCH_STATUS = 0
BEGIN
INSERT INTO CMS.Transactions
(
Amount, Description, PaymentStatus, PaymentDate, RefId, Type,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@Amount,
CONCAT(N'بک‌فیل عضویت باشگاه — UserId ', @UserId),
0, -- Success
@PurchasedAt,
CONCAT('UPP-BACKFILL-', @UserId, '-', @PackageId),
2, -- DepositExternal1
SYSUTCDATETIME(), NULL, SYSUTCDATETIME(), NULL, 0
);
SET @TxId = SCOPE_IDENTITY();
INSERT INTO CMS.UserPackagePurchases
(
UserId, PackageId, PurchaseMethod, PurchasedAt, Amount,
OrderId, TransactionId, Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@UserId, @PackageId, @PurchaseMethod, @PurchasedAt, @Amount,
NULL, @TxId, SYSUTCDATETIME(), NULL, SYSUTCDATETIME(), NULL, 0
);
SET @Synthetic += 1;
FETCH NEXT FROM cur INTO @UserId, @PackageId, @PurchaseMethod, @PurchasedAt, @Amount;
END
CLOSE cur;
DEALLOCATE cur;
PRINT CONCAT('Inserted synthetic UPP rows: ', @Synthetic);
DROP TABLE #BackfillCandidates;
------------------------------------------------------------
-- Verify invariant
------------------------------------------------------------
DECLARE @ClubCount INT =
(
SELECT COUNT(*) FROM CMS.ClubMemberships WHERE IsDeleted = 0
);
DECLARE @OrphansLeft INT =
(
SELECT COUNT(*)
FROM CMS.ClubMemberships m
WHERE m.IsDeleted = 0
AND NOT EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
LEFT JOIN CMS.Transactions t ON t.Id = upp.TransactionId AND t.IsDeleted = 0
WHERE upp.UserId = m.UserId
AND upp.IsDeleted = 0
AND (upp.TransactionId IS NULL OR t.PaymentStatus = 0)
)
);
DECLARE @SuccessfulBuyers INT =
(
SELECT COUNT(DISTINCT upp.UserId)
FROM CMS.UserPackagePurchases upp
LEFT JOIN CMS.Transactions t ON t.Id = upp.TransactionId AND t.IsDeleted = 0
WHERE upp.IsDeleted = 0
AND (upp.TransactionId IS NULL OR t.PaymentStatus = 0)
);
PRINT CONCAT('ClubMemberships: ', @ClubCount);
PRINT CONCAT('Distinct successful UPP users: ', @SuccessfulBuyers);
PRINT CONCAT('Orphans left (must be 0): ', @OrphansLeft);
IF @OrphansLeft > 0
BEGIN
ROLLBACK;
THROW 50001, 'Backfill incomplete — orphans remain; transaction rolled back.', 1;
END
COMMIT;
PRINT 'Backfill committed successfully.';
@@ -0,0 +1,246 @@
/*
DIAGNOSTIC ONLY — maps RefId LIKE 'manualy%' transactions to Users / WalletHistory.
Background (ChargeUserWallets.sql):
RefId = 'manualy' + ChangeLogId at charge time (original UserWalletChangeLogs id).
After migration/rebuild, WH ids shifted; RefrenceId on some rows stayed 0.
When RefrenceId=0, CreatedBy may store the Transaction Id as a fallback hint —
but Tx may never have been created; do NOT infer Tx from mobile or manualy suffix alone.
Rules used here:
- WH.RefrenceId > 0 → trusted link to Tx
- WH.RefrenceId = 0 → Tx ONLY if CreatedBy is numeric AND that Tx exists with RefId manualy%
- Tx → User via mobile (CreatedBy/LastModifiedBy) is informational, not used alone for Ref=0 WH
- manualy{N} suffix N = original ChangeLog id (may differ from current WH.Id)
Run on staging before script 6.
*/
SET NOCOUNT ON;
DECLARE @ManualyTx TABLE (
TransactionId BIGINT PRIMARY KEY,
RefId NVARCHAR(100) NOT NULL,
OriginalChangeLogId BIGINT NULL,
Amount BIGINT,
TxCreatedBy NVARCHAR(200),
TxLastModifiedBy NVARCHAR(200),
PaymentDate DATETIME2
);
INSERT INTO @ManualyTx (TransactionId, RefId, OriginalChangeLogId, Amount, TxCreatedBy, TxLastModifiedBy, PaymentDate)
SELECT
t.Id,
t.RefId,
TRY_CAST(
SUBSTRING(t.RefId, LEN(N'manualy') + 1, 20)
AS BIGINT
),
t.Amount,
t.CreatedBy,
t.LastModifiedBy,
t.PaymentDate
FROM CMS.Transactions t
WHERE t.IsDeleted = 0
AND t.RefId LIKE N'manualy%';
DECLARE @ManualyTxCount INT = (SELECT COUNT(*) FROM @ManualyTx);
PRINT CONCAT('manualy transactions in DB: ', @ManualyTxCount);
------------------------------------------------------------
-- 1) Each manualy Tx → User(s) via WH.RefrenceId / CreatedBy hint / mobile
------------------------------------------------------------
PRINT '=== 1) manualy Tx → User resolution ===';
SELECT
mt.TransactionId,
mt.RefId,
mt.OriginalChangeLogId,
mt.Amount,
mt.PaymentDate,
mt.TxCreatedBy,
mt.TxLastModifiedBy,
u_mobile.Id AS UserByMobile,
u_mobile.Mobile AS UserByMobilePhone,
u_mobile.FirstName AS UserByMobileFirst,
u_mobile.LastName AS UserByMobileLast,
h_linked.Id AS WhIdViaRef,
h_linked.WalletId AS WhWalletViaRef,
u_wh.Id AS UserViaWhRef,
h_hint.Id AS WhIdViaCreatedByHint,
h_hint.WalletId AS WhWalletViaHint,
u_hint.Id AS UserViaWhHint,
CASE
WHEN u_wh.Id IS NOT NULL THEN N'WH_REFRENCE_ID'
WHEN u_hint.Id IS NOT NULL THEN N'WH_CREATEDBY_HINT'
WHEN u_mobile.Id IS NOT NULL THEN N'TX_MOBILE_ONLY'
ELSE N'UNRESOLVED'
END AS PrimaryLink,
CASE
WHEN u_wh.Id IS NOT NULL AND u_hint.Id IS NOT NULL AND u_wh.Id <> u_hint.Id
THEN N'CONFLICT_WH_REF_VS_HINT'
WHEN u_wh.Id IS NOT NULL AND u_mobile.Id IS NOT NULL AND u_wh.Id <> u_mobile.Id
THEN N'CONFLICT_WH_REF_VS_MOBILE'
WHEN u_wh.Id IS NULL AND u_hint.Id IS NULL AND u_mobile.Id IS NULL
THEN N'NO_USER_FOUND'
ELSE N'OK'
END AS LinkQuality
FROM @ManualyTx mt
LEFT JOIN CMS.Users u_mobile ON u_mobile.IsDeleted = 0
AND u_mobile.Mobile IN (
NULLIF(LTRIM(RTRIM(mt.TxCreatedBy)), N''),
NULLIF(LTRIM(RTRIM(mt.TxLastModifiedBy)), N'')
)
LEFT JOIN CMS.UserWalletHistories h_linked ON h_linked.IsDeleted = 0
AND h_linked.RefrenceId = mt.TransactionId
LEFT JOIN CMS.UserWallets w_linked ON w_linked.Id = h_linked.WalletId AND w_linked.IsDeleted = 0
LEFT JOIN CMS.Users u_wh ON u_wh.Id = w_linked.UserId AND u_wh.IsDeleted = 0
LEFT JOIN CMS.UserWalletHistories h_hint ON h_hint.IsDeleted = 0
AND (h_hint.RefrenceId IS NULL OR h_hint.RefrenceId = 0)
AND TRY_CAST(h_hint.CreatedBy AS BIGINT) = mt.TransactionId
LEFT JOIN CMS.UserWallets w_hint ON w_hint.Id = h_hint.WalletId AND w_hint.IsDeleted = 0
LEFT JOIN CMS.Users u_hint ON u_hint.Id = w_hint.UserId AND u_hint.IsDeleted = 0
ORDER BY mt.TransactionId;
------------------------------------------------------------
-- 2) WH rows with RefrenceId=0 — CreatedBy hint analysis
------------------------------------------------------------
PRINT '=== 2) WalletHistory RefrenceId=0 (manualy charge candidates) ===';
SELECT
h.Id AS WalletHistoryId,
h.WalletId,
w.UserId AS WalletUserId,
u.Mobile AS WalletUserMobile,
h.RefrenceId AS WhRefrenceId,
h.ChangeValue,
h.Created AS WhCreated,
h.CreatedBy AS WhCreatedBy,
h.LastModifiedBy AS WhLastModifiedBy,
t_hint.Id AS TxByCreatedByHint,
t_hint.RefId AS TxHintRefId,
t_hint.Amount AS TxHintAmount,
u_hint_mobile.Id AS TxHintUserByMobile,
CASE
WHEN TRY_CAST(h.CreatedBy AS BIGINT) IS NULL OR TRY_CAST(h.CreatedBy AS BIGINT) = 0
THEN N'NO_NUMERIC_HINT'
WHEN t_hint.Id IS NULL
THEN N'HINT_POINTS_TO_MISSING_TX'
WHEN t_hint.RefId NOT LIKE N'manualy%'
THEN N'HINT_TX_NOT_MANUALY'
WHEN w.UserId = u_hint_mobile.Id
THEN N'FIX_LINK_WH_TO_HINT_TX'
WHEN u_hint_mobile.Id IS NOT NULL AND w.UserId <> u_hint_mobile.Id
THEN N'HINT_TX_USER_MISMATCH_WALLET'
ELSE N'FIX_LINK_REVIEW_MOBILE'
END AS WhZeroRefVerdict,
CASE
WHEN t_hint.Id IS NOT NULL AND t_hint.RefId LIKE N'manualy%'
THEN CONCAT(N'UPDATE WH ', h.Id, N' SET RefrenceId=', t_hint.Id)
WHEN TRY_CAST(h.CreatedBy AS BIGINT) IS NULL OR TRY_CAST(h.CreatedBy AS BIGINT) = 0
THEN N'Cannot auto-link — no CreatedBy Tx hint'
ELSE N'Tx from hint missing — may need manual charge review'
END AS SuggestedAction
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
LEFT JOIN CMS.Users u ON u.Id = w.UserId AND u.IsDeleted = 0
LEFT JOIN CMS.Transactions t_hint ON t_hint.Id = TRY_CAST(h.CreatedBy AS BIGINT)
AND t_hint.IsDeleted = 0
LEFT JOIN CMS.Users u_hint_mobile ON u_hint_mobile.IsDeleted = 0
AND u_hint_mobile.Mobile IN (
NULLIF(LTRIM(RTRIM(t_hint.CreatedBy)), N''),
NULLIF(LTRIM(RTRIM(t_hint.LastModifiedBy)), N'')
)
WHERE h.IsDeleted = 0
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0)
AND (
TRY_CAST(h.CreatedBy AS BIGINT) IS NOT NULL
OR h.LastModifiedBy LIKE N'09%'
OR EXISTS (
SELECT 1 FROM @ManualyTx mt
WHERE mt.OriginalChangeLogId = h.Id
)
)
ORDER BY h.Id;
------------------------------------------------------------
-- 3) manualy suffix vs current WH.Id (id-shift detector)
------------------------------------------------------------
PRINT '=== 3) manualy suffix N vs current WH.Id (id shift) ===';
SELECT
mt.TransactionId,
mt.RefId,
mt.OriginalChangeLogId AS ManualySuffixN,
h_by_suffix.Id AS CurrentWhIdWithSameIdAsSuffix,
h_by_suffix.RefrenceId AS ThatWhRefrenceId,
h_by_suffix.WalletId,
CASE
WHEN h_by_suffix.Id IS NULL THEN N'SUFFIX_WH_ID_GONE'
WHEN h_by_suffix.RefrenceId = mt.TransactionId THEN N'SUFFIX_WH_LINKED_OK'
WHEN h_by_suffix.RefrenceId IS NULL OR h_by_suffix.RefrenceId = 0
THEN N'SUFFIX_WH_EXISTS_BROKEN_REF'
ELSE N'SUFFIX_WH_POINTS_OTHER_TX'
END AS SuffixMatchStatus
FROM @ManualyTx mt
LEFT JOIN CMS.UserWalletHistories h_by_suffix ON h_by_suffix.Id = mt.OriginalChangeLogId
AND h_by_suffix.IsDeleted = 0
ORDER BY mt.TransactionId;
------------------------------------------------------------
-- 4) Distinct users in scope for script 6 (manualy-linked only)
------------------------------------------------------------
PRINT '=== 4) Users in manualy fix scope ===';
;WITH ScopeUsers AS (
SELECT DISTINCT w.UserId, mt.TransactionId, N'WH_REF' AS Source
FROM @ManualyTx mt
INNER JOIN CMS.UserWalletHistories h ON h.RefrenceId = mt.TransactionId AND h.IsDeleted = 0
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
UNION
SELECT DISTINCT w.UserId, t.Id, N'WH_CREATEDBY_HINT'
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
INNER JOIN CMS.Transactions t ON t.Id = TRY_CAST(h.CreatedBy AS BIGINT)
AND t.IsDeleted = 0 AND t.RefId LIKE N'manualy%'
WHERE h.IsDeleted = 0
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0)
AND TRY_CAST(h.CreatedBy AS BIGINT) > 0
UNION
SELECT DISTINCT u.Id, mt.TransactionId, N'TX_MOBILE'
FROM @ManualyTx mt
INNER JOIN CMS.Users u ON u.IsDeleted = 0
AND u.Mobile IN (
NULLIF(LTRIM(RTRIM(mt.TxCreatedBy)), N''),
NULLIF(LTRIM(RTRIM(mt.TxLastModifiedBy)), N'')
)
)
SELECT
su.UserId,
u.Mobile,
u.FirstName,
u.LastName,
u.PackagePurchaseMethod,
MIN(su.TransactionId) AS SampleManualyTxId,
STRING_AGG(su.Source, N', ') WITHIN GROUP (ORDER BY su.Source) AS LinkSources,
CASE WHEN EXISTS (
SELECT 1 FROM CMS.Transactions t2
WHERE t2.IsDeleted = 0 AND t2.RefId LIKE N'C4%'
AND EXISTS (
SELECT 1 FROM CMS.Users ux
WHERE ux.Id = su.UserId AND ux.IsDeleted = 0
AND ux.Mobile IN (NULLIF(t2.CreatedBy,N''), NULLIF(t2.LastModifiedBy,N''))
)
) THEN 1 ELSE 0 END AS AlsoHasC4Tx,
(SELECT COUNT(*) FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = su.UserId AND upp.IsDeleted = 0) AS UppCount
FROM ScopeUsers su
INNER JOIN CMS.Users u ON u.Id = su.UserId AND u.IsDeleted = 0
GROUP BY su.UserId, u.Mobile, u.FirstName, u.LastName, u.PackagePurchaseMethod
ORDER BY su.UserId;
PRINT 'Done (diagnostic only — no changes).';

Some files were not shown because too many files have changed in this diff Show More