33 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
106 changed files with 12949 additions and 162 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);
@@ -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
@@ -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)
}; };
} }
} }
@@ -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
};
}
}
@@ -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,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
} }
@@ -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.205</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
{ {
@@ -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;
}
@@ -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)
@@ -18,6 +18,7 @@ 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;
@@ -175,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);
@@ -277,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 =============
@@ -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).';
+104
View File
@@ -0,0 +1,104 @@
"TransactionId","RefId","OriginalChangeLogId","Amount","PaymentDate","TxCreatedBy","TxLastModifiedBy","UserByMobile","UserByMobilePhone","UserByMobileFirst","UserByMobileLast","WhIdViaRef","WhWalletViaRef","UserViaWhRef","WhIdViaCreatedByHint","WhWalletViaHint","UserViaWhHint","PrimaryLink","LinkQuality"
"23","manualy24","24","56000000","19/11/2025 01:24:24",,,,,,,"102","38","75",,,,"WH_REFRENCE_ID","OK"
"23","manualy24","24","56000000","19/11/2025 01:24:24",,,,,,,"216","24","47",,,,"WH_REFRENCE_ID","OK"
"24","manualy25","25","56000000","19/11/2025 01:24:24",,,,,,,"103","46","93",,,,"WH_REFRENCE_ID","OK"
"24","manualy25","25","56000000","19/11/2025 01:24:24",,,,,,,"217","6","50",,,,"WH_REFRENCE_ID","OK"
"25","manualy26","26","56000000","19/11/2025 01:24:24",,,,,,,"104","47","96","218","34","71","WH_REFRENCE_ID","CONFLICT_WH_REF_VS_HINT"
"26","manualy27","27","56000000","19/11/2025 01:24:24",,,,,,,"105","49","99","219","37","74","WH_REFRENCE_ID","CONFLICT_WH_REF_VS_HINT"
"28","manualy29","29","56000000","19/11/2025 01:24:24",,,,,,,"106","24","47",,,,"WH_REFRENCE_ID","OK"
"28","manualy29","29","56000000","19/11/2025 01:24:24",,,,,,,"221","42","88",,,,"WH_REFRENCE_ID","OK"
"29","manualy30","30","56000000","19/11/2025 01:24:24",,,,,,,"107","22","43",,,,"WH_REFRENCE_ID","OK"
"29","manualy30","30","56000000","19/11/2025 01:24:24",,,,,,,"222","62","114",,,,"WH_REFRENCE_ID","OK"
"30","manualy31","31","56000000","19/11/2025 01:24:24",,,,,,,"108","23","45",,,,"WH_REFRENCE_ID","OK"
"30","manualy31","31","56000000","19/11/2025 01:24:24",,,,,,,"223","13","7",,,,"WH_REFRENCE_ID","OK"
"31","manualy32","32","56000000","28/11/2025 05:27:38",,,,,,,"122","26","52",,,,"WH_REFRENCE_ID","OK"
"31","manualy32","32","56000000","28/11/2025 05:27:38",,,,,,,"224","15","9",,,,"WH_REFRENCE_ID","OK"
"42","manualy43","43","56000000","3/12/2025 09:49:45.92","09014638673","09014638673","116","09014638673","سالار","رضائی","138","64","116",,,,"WH_REFRENCE_ID","OK"
"42","manualy43","43","56000000","3/12/2025 09:49:45.92","09014638673","09014638673","116","09014638673","سالار","رضائی","235","24","47",,,,"WH_REFRENCE_ID","CONFLICT_WH_REF_VS_MOBILE"
"43","manualy44","44","56000000","3/12/2025 09:49:45.92","09056365323","09056365323","115","09056365323","حامد","دهقانی","139","63","115","236","32","69","WH_REFRENCE_ID","CONFLICT_WH_REF_VS_HINT"
"44","manualy45","45","56000000","3/12/2025 09:49:45.92","09126092994","09126092994","119","09126092994","علیرضا ","کریمی پیروز ","140","67","119",,,,"WH_REFRENCE_ID","OK"
"44","manualy45","45","56000000","3/12/2025 09:49:45.92","09126092994","09126092994","119","09126092994","علیرضا ","کریمی پیروز ","237","38","75",,,,"WH_REFRENCE_ID","CONFLICT_WH_REF_VS_MOBILE"
"45","manualy46","46","56000000","3/12/2025 09:49:45.92","09177325630","09177325630","113","09177325630","کاوس","بیگ اینالو","141","61","113",,,,"WH_REFRENCE_ID","OK"
"45","manualy46","46","56000000","3/12/2025 09:49:45.92","09177325630","09177325630","113","09177325630","کاوس","بیگ اینالو","238","6","50",,,,"WH_REFRENCE_ID","CONFLICT_WH_REF_VS_MOBILE"
"46","manualy47","47","56000000","3/12/2025 09:49:45.92","09384366040","09384366040","103","09384366040","علی ","دلال زاده","142","51","103",,,,"WH_REFRENCE_ID","OK"
"46","manualy47","47","56000000","3/12/2025 09:49:45.92","09384366040","09384366040","103","09384366040","علی ","دلال زاده","239","13","7",,,,"WH_REFRENCE_ID","CONFLICT_WH_REF_VS_MOBILE"
"56","manualy57","57","56000000","3/12/2025 09:49:45.92","09015793338","09015793338","71","09015793338","حمیدرضا","مرجانی","249","62","114","118","34","71","WH_REFRENCE_ID","CONFLICT_WH_REF_VS_HINT"
"57","manualy58","58","56000000","19/11/2025 01:24:24","09961354257","09961354257",,,,,"250","13","7","124","35","72","WH_REFRENCE_ID","CONFLICT_WH_REF_VS_HINT"
"58","manualy59","59","56000000","19/11/2025 01:24:24","09217672935","09217672935","69","09217672935","مهدی ","گیوی ","251","15","9","119","32","69","WH_REFRENCE_ID","CONFLICT_WH_REF_VS_HINT"
"59","manualy60","60","56000000","19/11/2025 01:24:24","09101692049","09101692049","74","09101692049","سجاد ","اداوی","252","22","43","120","37","74","WH_REFRENCE_ID","CONFLICT_WH_REF_VS_HINT"
"68","manualy69","69","56000000","8/12/2025 12:30:53.536666","09399172812","09399172812","142","09399172812","صغری","شبانکاره ",,,,,,,"TX_MOBILE_ONLY","OK"
"107","manualy292","292","56000000","30/12/2025 01:24:24",,,,,,,"186","15","9",,,,"WH_REFRENCE_ID","OK"
"107","manualy292","292","56000000","30/12/2025 01:24:24",,,,,,,"292","118","170",,,,"WH_REFRENCE_ID","OK"
"107","manualy292","292","56000000","30/12/2025 01:24:24",,,,,,,"40399","34","71",,,,"WH_REFRENCE_ID","OK"
"108","manualy293","293","56000000","31/12/2025 01:24:24",,,,,,,"187","22","43",,,,"WH_REFRENCE_ID","OK"
"108","manualy293","293","56000000","31/12/2025 01:24:24",,,,,,,"293","119","171",,,,"WH_REFRENCE_ID","OK"
"108","manualy293","293","56000000","31/12/2025 01:24:24",,,,,,,"40400","38","75",,,,"WH_REFRENCE_ID","OK"
"109","manualy294","294","56000000","31/12/2025 01:24:24",,,,,,,"188","23","45",,,,"WH_REFRENCE_ID","OK"
"109","manualy294","294","56000000","31/12/2025 01:24:24",,,,,,,"294","120","172",,,,"WH_REFRENCE_ID","OK"
"109","manualy294","294","56000000","31/12/2025 01:24:24",,,,,,,"40401","59","111",,,,"WH_REFRENCE_ID","OK"
"110","manualy295","295","56000000","1/1/2026 17:07:53.2301978",,,,,,,"189","24","47",,,,"WH_REFRENCE_ID","OK"
"110","manualy295","295","56000000","1/1/2026 17:07:53.2301978",,,,,,,"295","123","175",,,,"WH_REFRENCE_ID","OK"
"110","manualy295","295","56000000","1/1/2026 17:07:53.2301978",,,,,,,"40402","134","186",,,,"WH_REFRENCE_ID","OK"
"118","34","71","09015793338","0","1","3/12/2025 09:49:45.92","56","09015793338","56","manualy57","56000000","71","FIX_LINK_WH_TO_HINT_TX","UPDATE WH 118 SET RefrenceId=56"
"119","32","69","09217672935","0","1","19/11/2025 01:24:24","58","09217672935","58","manualy59","56000000","69","FIX_LINK_WH_TO_HINT_TX","UPDATE WH 119 SET RefrenceId=58"
"120","37","74","09101692049","0","1","19/11/2025 01:24:24","59","09101692049","59","manualy60","56000000","74","FIX_LINK_WH_TO_HINT_TX","UPDATE WH 120 SET RefrenceId=59"
"121","12","2","09387342688","0","1","19/12/2025 22:00:40.276666","0","09387342688",,,,,"NO_NUMERIC_HINT","Cannot auto-link — no CreatedBy Tx hint"
"124","35","72","09124678697","0","1","19/11/2025 01:24:24","57","09961354257","57","manualy58","56000000",,"FIX_LINK_REVIEW_MOBILE","UPDATE WH 124 SET RefrenceId=57"
"201","110","162","09199877503","0","1","20/12/2025 02:24:42.771904","92","162",,,,,"HINT_POINTS_TO_MISSING_TX","Tx from hint missing — may need manual charge review"
"209","32","69","09217672935","0","0","20/12/2025 10:30:49.186666","16","SP","16","localwallet-d0870aee-8a21-4cfc-a5a0-362b25795af0","10000",,"HINT_TX_NOT_MANUALY","Tx from hint missing — may need manual charge review"
"218","34","71","09015793338","0","0","20/12/2025 10:31:12.52","25","SP","25","manualy26","56000000",,"FIX_LINK_REVIEW_MOBILE","UPDATE WH 218 SET RefrenceId=25"
"219","37","74","09101692049","0","0","20/12/2025 10:31:12.52","26","SP","26","manualy27","56000000",,"FIX_LINK_REVIEW_MOBILE","UPDATE WH 219 SET RefrenceId=26"
"236","32","69","09217672935","0","0","20/12/2025 10:38:43.333333","43","SP","43","manualy44","56000000","115","HINT_TX_USER_MISMATCH_WALLET","UPDATE WH 236 SET RefrenceId=43"
"245","34","71","09015793338","0","0","20/12/2025 10:39:58.833333","52","SP","52","localwallet-533cd27c-9ff5-497b-b23d-68df374c1107","37035000",,"HINT_TX_NOT_MANUALY","Tx from hint missing — may need manual charge review"
"246","37","74","09101692049","0","0","20/12/2025 10:39:58.833333","53","SP","53","C4_N9894294","56000000","88","HINT_TX_NOT_MANUALY","Tx from hint missing — may need manual charge review"
"23","manualy24","24",,,,"SUFFIX_WH_ID_GONE"
"24","manualy25","25",,,,"SUFFIX_WH_ID_GONE"
"25","manualy26","26",,,,"SUFFIX_WH_ID_GONE"
"26","manualy27","27",,,,"SUFFIX_WH_ID_GONE"
"28","manualy29","29",,,,"SUFFIX_WH_ID_GONE"
"29","manualy30","30",,,,"SUFFIX_WH_ID_GONE"
"30","manualy31","31",,,,"SUFFIX_WH_ID_GONE"
"31","manualy32","32",,,,"SUFFIX_WH_ID_GONE"
"42","manualy43","43",,,,"SUFFIX_WH_ID_GONE"
"43","manualy44","44",,,,"SUFFIX_WH_ID_GONE"
"44","manualy45","45",,,,"SUFFIX_WH_ID_GONE"
"45","manualy46","46",,,,"SUFFIX_WH_ID_GONE"
"46","manualy47","47",,,,"SUFFIX_WH_ID_GONE"
"56","manualy57","57",,,,"SUFFIX_WH_ID_GONE"
"57","manualy58","58",,,,"SUFFIX_WH_ID_GONE"
"58","manualy59","59",,,,"SUFFIX_WH_ID_GONE"
"59","manualy60","60",,,,"SUFFIX_WH_ID_GONE"
"68","manualy69","69",,,,"SUFFIX_WH_ID_GONE"
"107","manualy292","292","292","107","118","SUFFIX_WH_LINKED_OK"
"108","manualy293","293","293","108","119","SUFFIX_WH_LINKED_OK"
"109","manualy294","294","294","109","120","SUFFIX_WH_LINKED_OK"
"110","manualy295","295","295","110","123","SUFFIX_WH_LINKED_OK"
"7","09128676640","مهدی","صیفی","3","30","WH_REF, WH_REF, WH_REF","0","0"
"9","09387095611","مهدی","مرجانی","3","31","WH_REF, WH_REF, WH_REF","0","0"
"43","09039033341","کریم","خادمی ","3","29","WH_REF, WH_REF, WH_REF","0","0"
"45","09121150062","علی","دلال زاده","3","30","WH_REF, WH_REF","0","0"
"47","09123122544","صدیقه","منعمیان","3","23","WH_REF, WH_REF, WH_REF, WH_REF","0","0"
"50","09207316560","مرتضی ","اینالو","1","24","WH_REF, WH_REF","0","1"
"52","09178327020","حمیدرضا ","اسمعیلی ","3","31","WH_REF","0","0"
"69","09217672935","مهدی ","گیوی ","3","43","TX_MOBILE, WH_CREATEDBY_HINT, WH_CREATEDBY_HINT","0","0"
"71","09015793338","حمیدرضا","مرجانی","3","25","TX_MOBILE, WH_CREATEDBY_HINT, WH_CREATEDBY_HINT, WH_REF","0","0"
"72","09124678697","مژگان","گیوی","3","57","WH_CREATEDBY_HINT","0","0"
"74","09101692049","سجاد ","اداوی","3","26","TX_MOBILE, WH_CREATEDBY_HINT, WH_CREATEDBY_HINT","0","0"
"75","09353851578","پویا","عبدی ","3","23","WH_REF, WH_REF, WH_REF","0","0"
"88","09172606097","کریم","رعیت پیشه","1","28","WH_REF","1","0"
"93","09122156286","ریحانه سادات ","هاشمی نصر ","3","24","WH_REF","0","0"
"96","09125020729","غلامرضا ","عبدی ","3","25","WH_REF","0","0"
"99","09106519822","هستی","خادمی","3","26","WH_REF","0","0"
"103","09384366040","علی ","دلال زاده","3","46","TX_MOBILE, WH_REF","0","0"
"111","09352828086","انسیه","توسلیان","1","109","WH_REF","0","0"
"113","09177325630","کاوس","بیگ اینالو","3","45","TX_MOBILE, WH_REF","0","0"
"114","09397939699","اکرم","دهقانی","1","29","WH_REF, WH_REF","1","0"
"115","09056365323","حامد","دهقانی","3","43","TX_MOBILE, WH_REF","0","0"
"116","09014638673","سالار","رضائی","3","42","TX_MOBILE, WH_REF","0","0"
"119","09126092994","علیرضا ","کریمی پیروز ","3","44","TX_MOBILE, WH_REF","0","0"
"142","09399172812","صغری","شبانکاره ","3","68","TX_MOBILE","0","0"
"170","09120991323","لیلا","خدارحمی دره میانه","3","107","WH_REF","0","0"
"171","09375724408","مژده","بنی هاشمی","3","108","WH_REF","0","0"
"172","09121355832","مهرافشان","زاهدنیا","3","109","WH_REF","0","0"
"175","09122332219","ناهید ","حسن زاده مژدهی","3","110","WH_REF","0","0"
"186","09395618852","احمد","عالم","2","110","WH_REF","0","2"
1 TransactionId RefId OriginalChangeLogId Amount PaymentDate TxCreatedBy TxLastModifiedBy UserByMobile UserByMobilePhone UserByMobileFirst UserByMobileLast WhIdViaRef WhWalletViaRef UserViaWhRef WhIdViaCreatedByHint WhWalletViaHint UserViaWhHint PrimaryLink LinkQuality
2 23 manualy24 24 56000000 19/11/2025 01:24:24 102 38 75 WH_REFRENCE_ID OK
3 23 manualy24 24 56000000 19/11/2025 01:24:24 216 24 47 WH_REFRENCE_ID OK
4 24 manualy25 25 56000000 19/11/2025 01:24:24 103 46 93 WH_REFRENCE_ID OK
5 24 manualy25 25 56000000 19/11/2025 01:24:24 217 6 50 WH_REFRENCE_ID OK
6 25 manualy26 26 56000000 19/11/2025 01:24:24 104 47 96 218 34 71 WH_REFRENCE_ID CONFLICT_WH_REF_VS_HINT
7 26 manualy27 27 56000000 19/11/2025 01:24:24 105 49 99 219 37 74 WH_REFRENCE_ID CONFLICT_WH_REF_VS_HINT
8 28 manualy29 29 56000000 19/11/2025 01:24:24 106 24 47 WH_REFRENCE_ID OK
9 28 manualy29 29 56000000 19/11/2025 01:24:24 221 42 88 WH_REFRENCE_ID OK
10 29 manualy30 30 56000000 19/11/2025 01:24:24 107 22 43 WH_REFRENCE_ID OK
11 29 manualy30 30 56000000 19/11/2025 01:24:24 222 62 114 WH_REFRENCE_ID OK
12 30 manualy31 31 56000000 19/11/2025 01:24:24 108 23 45 WH_REFRENCE_ID OK
13 30 manualy31 31 56000000 19/11/2025 01:24:24 223 13 7 WH_REFRENCE_ID OK
14 31 manualy32 32 56000000 28/11/2025 05:27:38 122 26 52 WH_REFRENCE_ID OK
15 31 manualy32 32 56000000 28/11/2025 05:27:38 224 15 9 WH_REFRENCE_ID OK
16 42 manualy43 43 56000000 3/12/2025 09:49:45.92 09014638673 09014638673 116 09014638673 سالار رضائی 138 64 116 WH_REFRENCE_ID OK
17 42 manualy43 43 56000000 3/12/2025 09:49:45.92 09014638673 09014638673 116 09014638673 سالار رضائی 235 24 47 WH_REFRENCE_ID CONFLICT_WH_REF_VS_MOBILE
18 43 manualy44 44 56000000 3/12/2025 09:49:45.92 09056365323 09056365323 115 09056365323 حامد دهقانی 139 63 115 236 32 69 WH_REFRENCE_ID CONFLICT_WH_REF_VS_HINT
19 44 manualy45 45 56000000 3/12/2025 09:49:45.92 09126092994 09126092994 119 09126092994 علیرضا کریمی پیروز 140 67 119 WH_REFRENCE_ID OK
20 44 manualy45 45 56000000 3/12/2025 09:49:45.92 09126092994 09126092994 119 09126092994 علیرضا کریمی پیروز 237 38 75 WH_REFRENCE_ID CONFLICT_WH_REF_VS_MOBILE
21 45 manualy46 46 56000000 3/12/2025 09:49:45.92 09177325630 09177325630 113 09177325630 کاوس بیگ اینالو 141 61 113 WH_REFRENCE_ID OK
22 45 manualy46 46 56000000 3/12/2025 09:49:45.92 09177325630 09177325630 113 09177325630 کاوس بیگ اینالو 238 6 50 WH_REFRENCE_ID CONFLICT_WH_REF_VS_MOBILE
23 46 manualy47 47 56000000 3/12/2025 09:49:45.92 09384366040 09384366040 103 09384366040 علی دلال زاده 142 51 103 WH_REFRENCE_ID OK
24 46 manualy47 47 56000000 3/12/2025 09:49:45.92 09384366040 09384366040 103 09384366040 علی دلال زاده 239 13 7 WH_REFRENCE_ID CONFLICT_WH_REF_VS_MOBILE
25 56 manualy57 57 56000000 3/12/2025 09:49:45.92 09015793338 09015793338 71 09015793338 حمیدرضا مرجانی 249 62 114 118 34 71 WH_REFRENCE_ID CONFLICT_WH_REF_VS_HINT
26 57 manualy58 58 56000000 19/11/2025 01:24:24 09961354257 09961354257 250 13 7 124 35 72 WH_REFRENCE_ID CONFLICT_WH_REF_VS_HINT
27 58 manualy59 59 56000000 19/11/2025 01:24:24 09217672935 09217672935 69 09217672935 مهدی گیوی 251 15 9 119 32 69 WH_REFRENCE_ID CONFLICT_WH_REF_VS_HINT
28 59 manualy60 60 56000000 19/11/2025 01:24:24 09101692049 09101692049 74 09101692049 سجاد اداوی 252 22 43 120 37 74 WH_REFRENCE_ID CONFLICT_WH_REF_VS_HINT
29 68 manualy69 69 56000000 8/12/2025 12:30:53.536666 09399172812 09399172812 142 09399172812 صغری شبانکاره TX_MOBILE_ONLY OK
30 107 manualy292 292 56000000 30/12/2025 01:24:24 186 15 9 WH_REFRENCE_ID OK
31 107 manualy292 292 56000000 30/12/2025 01:24:24 292 118 170 WH_REFRENCE_ID OK
32 107 manualy292 292 56000000 30/12/2025 01:24:24 40399 34 71 WH_REFRENCE_ID OK
33 108 manualy293 293 56000000 31/12/2025 01:24:24 187 22 43 WH_REFRENCE_ID OK
34 108 manualy293 293 56000000 31/12/2025 01:24:24 293 119 171 WH_REFRENCE_ID OK
35 108 manualy293 293 56000000 31/12/2025 01:24:24 40400 38 75 WH_REFRENCE_ID OK
36 109 manualy294 294 56000000 31/12/2025 01:24:24 188 23 45 WH_REFRENCE_ID OK
37 109 manualy294 294 56000000 31/12/2025 01:24:24 294 120 172 WH_REFRENCE_ID OK
38 109 manualy294 294 56000000 31/12/2025 01:24:24 40401 59 111 WH_REFRENCE_ID OK
39 110 manualy295 295 56000000 1/1/2026 17:07:53.2301978 189 24 47 WH_REFRENCE_ID OK
40 110 manualy295 295 56000000 1/1/2026 17:07:53.2301978 295 123 175 WH_REFRENCE_ID OK
41 110 manualy295 295 56000000 1/1/2026 17:07:53.2301978 40402 134 186 WH_REFRENCE_ID OK
42 118 34 71 09015793338 0 1 3/12/2025 09:49:45.92 56 09015793338 56 manualy57 56000000 71 FIX_LINK_WH_TO_HINT_TX UPDATE WH 118 SET RefrenceId=56
43 119 32 69 09217672935 0 1 19/11/2025 01:24:24 58 09217672935 58 manualy59 56000000 69 FIX_LINK_WH_TO_HINT_TX UPDATE WH 119 SET RefrenceId=58
44 120 37 74 09101692049 0 1 19/11/2025 01:24:24 59 09101692049 59 manualy60 56000000 74 FIX_LINK_WH_TO_HINT_TX UPDATE WH 120 SET RefrenceId=59
45 121 12 2 09387342688 0 1 19/12/2025 22:00:40.276666 0 09387342688 NO_NUMERIC_HINT Cannot auto-link — no CreatedBy Tx hint
46 124 35 72 09124678697 0 1 19/11/2025 01:24:24 57 09961354257 57 manualy58 56000000 FIX_LINK_REVIEW_MOBILE UPDATE WH 124 SET RefrenceId=57
47 201 110 162 09199877503 0 1 20/12/2025 02:24:42.771904 92 162 HINT_POINTS_TO_MISSING_TX Tx from hint missing — may need manual charge review
48 209 32 69 09217672935 0 0 20/12/2025 10:30:49.186666 16 SP 16 localwallet-d0870aee-8a21-4cfc-a5a0-362b25795af0 10000 HINT_TX_NOT_MANUALY Tx from hint missing — may need manual charge review
49 218 34 71 09015793338 0 0 20/12/2025 10:31:12.52 25 SP 25 manualy26 56000000 FIX_LINK_REVIEW_MOBILE UPDATE WH 218 SET RefrenceId=25
50 219 37 74 09101692049 0 0 20/12/2025 10:31:12.52 26 SP 26 manualy27 56000000 FIX_LINK_REVIEW_MOBILE UPDATE WH 219 SET RefrenceId=26
51 236 32 69 09217672935 0 0 20/12/2025 10:38:43.333333 43 SP 43 manualy44 56000000 115 HINT_TX_USER_MISMATCH_WALLET UPDATE WH 236 SET RefrenceId=43
52 245 34 71 09015793338 0 0 20/12/2025 10:39:58.833333 52 SP 52 localwallet-533cd27c-9ff5-497b-b23d-68df374c1107 37035000 HINT_TX_NOT_MANUALY Tx from hint missing — may need manual charge review
53 246 37 74 09101692049 0 0 20/12/2025 10:39:58.833333 53 SP 53 C4_N9894294 56000000 88 HINT_TX_NOT_MANUALY Tx from hint missing — may need manual charge review
54 23 manualy24 24 SUFFIX_WH_ID_GONE
55 24 manualy25 25 SUFFIX_WH_ID_GONE
56 25 manualy26 26 SUFFIX_WH_ID_GONE
57 26 manualy27 27 SUFFIX_WH_ID_GONE
58 28 manualy29 29 SUFFIX_WH_ID_GONE
59 29 manualy30 30 SUFFIX_WH_ID_GONE
60 30 manualy31 31 SUFFIX_WH_ID_GONE
61 31 manualy32 32 SUFFIX_WH_ID_GONE
62 42 manualy43 43 SUFFIX_WH_ID_GONE
63 43 manualy44 44 SUFFIX_WH_ID_GONE
64 44 manualy45 45 SUFFIX_WH_ID_GONE
65 45 manualy46 46 SUFFIX_WH_ID_GONE
66 46 manualy47 47 SUFFIX_WH_ID_GONE
67 56 manualy57 57 SUFFIX_WH_ID_GONE
68 57 manualy58 58 SUFFIX_WH_ID_GONE
69 58 manualy59 59 SUFFIX_WH_ID_GONE
70 59 manualy60 60 SUFFIX_WH_ID_GONE
71 68 manualy69 69 SUFFIX_WH_ID_GONE
72 107 manualy292 292 292 107 118 SUFFIX_WH_LINKED_OK
73 108 manualy293 293 293 108 119 SUFFIX_WH_LINKED_OK
74 109 manualy294 294 294 109 120 SUFFIX_WH_LINKED_OK
75 110 manualy295 295 295 110 123 SUFFIX_WH_LINKED_OK
76 7 09128676640 مهدی صیفی 3 30 WH_REF, WH_REF, WH_REF 0 0
77 9 09387095611 مهدی مرجانی 3 31 WH_REF, WH_REF, WH_REF 0 0
78 43 09039033341 کریم خادمی 3 29 WH_REF, WH_REF, WH_REF 0 0
79 45 09121150062 علی دلال زاده 3 30 WH_REF, WH_REF 0 0
80 47 09123122544 صدیقه منعمیان 3 23 WH_REF, WH_REF, WH_REF, WH_REF 0 0
81 50 09207316560 مرتضی اینالو 1 24 WH_REF, WH_REF 0 1
82 52 09178327020 حمیدرضا اسمعیلی 3 31 WH_REF 0 0
83 69 09217672935 مهدی گیوی 3 43 TX_MOBILE, WH_CREATEDBY_HINT, WH_CREATEDBY_HINT 0 0
84 71 09015793338 حمیدرضا مرجانی 3 25 TX_MOBILE, WH_CREATEDBY_HINT, WH_CREATEDBY_HINT, WH_REF 0 0
85 72 09124678697 مژگان گیوی 3 57 WH_CREATEDBY_HINT 0 0
86 74 09101692049 سجاد اداوی 3 26 TX_MOBILE, WH_CREATEDBY_HINT, WH_CREATEDBY_HINT 0 0
87 75 09353851578 پویا عبدی 3 23 WH_REF, WH_REF, WH_REF 0 0
88 88 09172606097 کریم رعیت پیشه 1 28 WH_REF 1 0
89 93 09122156286 ریحانه سادات هاشمی نصر 3 24 WH_REF 0 0
90 96 09125020729 غلامرضا عبدی 3 25 WH_REF 0 0
91 99 09106519822 هستی خادمی 3 26 WH_REF 0 0
92 103 09384366040 علی دلال زاده 3 46 TX_MOBILE, WH_REF 0 0
93 111 09352828086 انسیه توسلیان 1 109 WH_REF 0 0
94 113 09177325630 کاوس بیگ اینالو 3 45 TX_MOBILE, WH_REF 0 0
95 114 09397939699 اکرم دهقانی 1 29 WH_REF, WH_REF 1 0
96 115 09056365323 حامد دهقانی 3 43 TX_MOBILE, WH_REF 0 0
97 116 09014638673 سالار رضائی 3 42 TX_MOBILE, WH_REF 0 0
98 119 09126092994 علیرضا کریمی پیروز 3 44 TX_MOBILE, WH_REF 0 0
99 142 09399172812 صغری شبانکاره 3 68 TX_MOBILE 0 0
100 170 09120991323 لیلا خدارحمی دره میانه 3 107 WH_REF 0 0
101 171 09375724408 مژده بنی هاشمی 3 108 WH_REF 0 0
102 172 09121355832 مهرافشان زاهدنیا 3 109 WH_REF 0 0
103 175 09122332219 ناهید حسن زاده مژدهی 3 110 WH_REF 0 0
104 186 09395618852 احمد عالم 2 110 WH_REF 0 2
@@ -0,0 +1,90 @@
-- =============================================
-- Diagnose: Leg counts — all subtree vs active-only vs gold/silver
-- Usage: SET @RootUserId to the tree owner UserId
-- =============================================
DECLARE @RootUserId BIGINT = 1; -- ← change to target user
DECLARE @GoldPackageId BIGINT = (
SELECT TOP 1 Id FROM CMS.Packages
WHERE IsDeleted = 0 AND IsActive = 1 AND IsBasePackage = 1
ORDER BY Id
);
DECLARE @SilverPackageId BIGINT = (
SELECT TOP 1 Id FROM CMS.Packages
WHERE IsDeleted = 0 AND IsActive = 1 AND IsBasePackage = 0
AND (Title LIKE N'%نقر%' OR MaxBalancesPerLeg <= 30)
ORDER BY Id
);
DECLARE @LeftChildId BIGINT = (
SELECT Id FROM CMS.Users
WHERE NetworkParentId = @RootUserId AND LegPosition = 0 AND IsDeleted = 0
);
DECLARE @RightChildId BIGINT = (
SELECT Id FROM CMS.Users
WHERE NetworkParentId = @RootUserId AND LegPosition = 1 AND IsDeleted = 0
);
;WITH LegUsers AS (
SELECT @LeftChildId AS RootLegUserId, 0 AS LegSide
WHERE @LeftChildId IS NOT NULL
UNION ALL
SELECT @RightChildId, 1
WHERE @RightChildId IS NOT NULL
),
Subtree AS (
SELECT lu.RootLegUserId, lu.LegSide, lu.RootLegUserId AS UserId
FROM LegUsers lu
UNION ALL
SELECT s.RootLegUserId, s.LegSide, u.Id
FROM Subtree s
INNER JOIN CMS.Users u ON u.NetworkParentId = s.UserId AND u.IsDeleted = 0
),
AllSubtreeCounts AS (
SELECT LegSide, COUNT(DISTINCT UserId) AS Cnt
FROM Subtree
GROUP BY LegSide
),
ActiveClubCounts AS (
SELECT s.LegSide, COUNT(DISTINCT s.UserId) AS Cnt
FROM Subtree s
INNER JOIN CMS.ClubMemberships cm
ON cm.UserId = s.UserId AND cm.IsActive = 1 AND cm.IsDeleted = 0
GROUP BY s.LegSide
),
ActivePackageHolders AS (
SELECT
s.LegSide,
cc.PackageId,
COUNT(DISTINCT s.UserId) AS HolderCount
FROM Subtree s
INNER JOIN CMS.ClubMemberships cm ON cm.UserId = s.UserId AND cm.IsActive = 1 AND cm.IsDeleted = 0
INNER JOIN CMS.ClubMembershipCycles cc ON cc.UserId = s.UserId AND cc.IsCurrentCycle = 1
GROUP BY s.LegSide, cc.PackageId
)
SELECT
@RootUserId AS RootUserId,
-- All subtree (old سازمان چپ/راست)
ISNULL((SELECT Cnt FROM AllSubtreeCounts WHERE LegSide = 0), 0) AS AllLeft,
ISNULL((SELECT Cnt FROM AllSubtreeCounts WHERE LegSide = 1), 0) AS AllRight,
-- Active club only (new سازمان چپ/راست / کل)
ISNULL((SELECT Cnt FROM ActiveClubCounts WHERE LegSide = 0), 0) AS ActiveLeft,
ISNULL((SELECT Cnt FROM ActiveClubCounts WHERE LegSide = 1), 0) AS ActiveRight,
ISNULL((SELECT Cnt FROM ActiveClubCounts WHERE LegSide = 0), 0)
+ ISNULL((SELECT Cnt FROM ActiveClubCounts WHERE LegSide = 1), 0) AS ActiveTotal,
-- Gold / Silver package holders (active + current cycle)
ISNULL((SELECT HolderCount FROM ActivePackageHolders WHERE LegSide = 0 AND PackageId = @GoldPackageId), 0) AS GoldLeft,
ISNULL((SELECT HolderCount FROM ActivePackageHolders WHERE LegSide = 1 AND PackageId = @GoldPackageId), 0) AS GoldRight,
ISNULL((SELECT HolderCount FROM ActivePackageHolders WHERE LegSide = 0 AND PackageId = @SilverPackageId), 0) AS SilverLeft,
ISNULL((SELECT HolderCount FROM ActivePackageHolders WHERE LegSide = 1 AND PackageId = @SilverPackageId), 0) AS SilverRight,
-- Gaps: Active − (Gold+Silver) per leg
ISNULL((SELECT Cnt FROM ActiveClubCounts WHERE LegSide = 0), 0)
- ISNULL((SELECT HolderCount FROM ActivePackageHolders WHERE LegSide = 0 AND PackageId = @GoldPackageId), 0)
- ISNULL((SELECT HolderCount FROM ActivePackageHolders WHERE LegSide = 0 AND PackageId = @SilverPackageId), 0) AS GapLeft_ActiveMinusPackages,
ISNULL((SELECT Cnt FROM ActiveClubCounts WHERE LegSide = 1), 0)
- ISNULL((SELECT HolderCount FROM ActivePackageHolders WHERE LegSide = 1 AND PackageId = @GoldPackageId), 0)
- ISNULL((SELECT HolderCount FROM ActivePackageHolders WHERE LegSide = 1 AND PackageId = @SilverPackageId), 0) AS GapRight_ActiveMinusPackages
OPTION (MAXRECURSION 0);
@@ -0,0 +1,116 @@
/*
Rollback for Backfill_UserPackagePurchases_FromClubMemberships.sql
What it undoes:
A) 77 synthetic rows: Tx with RefId LIKE 'UPP-BACKFILL-%' + their UPP
B) 24 ManualPayment-linked UPP rows inserted by backfill
(same TransactionId as Approved ManualPayments — NOT deleting those Transactions)
BEFORE RUN:
Set @BackfillStartedAt to a timestamp slightly BEFORE you ran the backfill
(e.g. if you ran at 06:40, use '2026-08-14 06:00:00').
Part B only deletes UPP with Created >= that time so later real CreateManualPayment rows stay.
Safe to preview: leave @Commit = 0 (default) to only PRINT counts inside a rolled-back tran.
Set @Commit = 1 to actually apply.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @BackfillStartedAt DATETIME2 = '2026-08-14 00:00:00'; -- << adjust
DECLARE @Commit BIT = 0; -- 0 = dry-run (rollback), 1 = commit deletes
BEGIN TRAN;
------------------------------------------------------------
-- A) Synthetic Tx + UPP (RefId marker)
------------------------------------------------------------
DECLARE @SyntheticUpp TABLE (Id BIGINT PRIMARY KEY, TransactionId BIGINT);
INSERT INTO @SyntheticUpp (Id, TransactionId)
SELECT upp.Id, upp.TransactionId
FROM CMS.UserPackagePurchases upp
INNER JOIN CMS.Transactions t ON t.Id = upp.TransactionId
WHERE t.RefId LIKE N'UPP-BACKFILL-%';
DECLARE @SynthUppCount INT = (SELECT COUNT(*) FROM @SyntheticUpp);
DECLARE @SynthTxCount INT =
(
SELECT COUNT(*)
FROM CMS.Transactions t
WHERE t.RefId LIKE N'UPP-BACKFILL-%'
);
DELETE upp
FROM CMS.UserPackagePurchases upp
INNER JOIN @SyntheticUpp s ON s.Id = upp.Id;
DELETE t
FROM CMS.Transactions t
WHERE t.RefId LIKE N'UPP-BACKFILL-%';
PRINT CONCAT('Deleted synthetic UPP: ', @SynthUppCount);
PRINT CONCAT('Deleted synthetic Transactions: ', @SynthTxCount);
------------------------------------------------------------
-- B) UPP created from ManualPayments reuse (no new Tx)
------------------------------------------------------------
DECLARE @ManualUpp TABLE (Id BIGINT PRIMARY KEY);
INSERT INTO @ManualUpp (Id)
SELECT upp.Id
FROM CMS.UserPackagePurchases upp
INNER JOIN CMS.ManualPayments mp
ON mp.TransactionId = upp.TransactionId
AND mp.IsDeleted = 0
AND mp.Status = 1 -- Approved
WHERE upp.IsDeleted = 0
AND upp.PurchaseMethod = 3 -- Manual
AND upp.Created >= @BackfillStartedAt
-- exclude anything already covered as synthetic (shouldn't overlap)
AND NOT EXISTS (
SELECT 1 FROM CMS.Transactions t
WHERE t.Id = upp.TransactionId AND t.RefId LIKE N'UPP-BACKFILL-%'
);
DECLARE @ManualUppCount INT = (SELECT COUNT(*) FROM @ManualUpp);
DELETE upp
FROM CMS.UserPackagePurchases upp
INNER JOIN @ManualUpp m ON m.Id = upp.Id;
PRINT CONCAT('Deleted ManualPayment-linked UPP (Created >= @BackfillStartedAt): ', @ManualUppCount);
PRINT CONCAT('@BackfillStartedAt used: ', CONVERT(varchar(30), @BackfillStartedAt, 126));
------------------------------------------------------------
-- Optional sanity after rollback
------------------------------------------------------------
DECLARE @Orphans 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)
)
);
PRINT CONCAT('Club members without successful UPP after rollback: ', @Orphans);
PRINT '(Expect around 101 if dump-era state, if no new purchases since.)';
IF @Commit = 1
BEGIN
COMMIT;
PRINT 'Rollback COMMITTED.';
END
ELSE
BEGIN
ROLLBACK;
PRINT 'Dry-run only — ROLLBACK. Set @Commit = 1 after verifying counts.';
END
@@ -0,0 +1,145 @@
/*
Update DayaLoanContracts.ContractNumber from MerchantCreditList.
Match: NationalCode (unique cases).
Special case — same NationalCode 2490371436 on TWO users (two mobiles):
UserId=50 (09207316560, Created 2025-11-11) → C4_D8815963 (فعال‌سازی 1404/8/24 = 2025-11-15)
UserId=51 (09366426797, Created 2025-11-12) → C4_G8945289 (فعال‌سازی 1404/8/30 = 2025-11-21)
User 51 may have no DayaLoanContracts row yet → INSERT.
Script defaults to ROLLBACK; uncomment COMMIT after verifying.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRAN;
------------------------------------------------------------
-- 1) Unique NationalCodes → UPDATE ContractNumber
------------------------------------------------------------
;WITH Src AS (
SELECT *
FROM (VALUES
(N'C4_C11231802', N'0312980973'),
(N'C4_H10706299', N'4060667961'),
(N'C4_E10490523', N'4072306754'),
(N'C4_C10337736', N'0054140161'),
(N'C4_Q10727431', N'5279791253'),
(N'C4_W10568862', N'1960144804'),
(N'C4_V10338780', N'4072240567'),
(N'C4_G10538762', N'6639906902'),
(N'C4_N10921338', N'0943060761'),
(N'C4_P9320383', N'2572729636'),
(N'C4_Y9276146', N'0012011061'),
(N'C4_Q9465813', N'2480017796'),
(N'C4_A9107753', N'0450314103'),
(N'C4_B9348378', N'0452314488'),
(N'C4_U9595222', N'2062876815'),
(N'C4_X9630520', N'0680164286'),
(N'C4_J9199624', N'0681699401'),
(N'C4_B9813553', N'0055680364'),
(N'C4_M9994735', N'0793851572'),
(N'C4_S9301452', N'0082242925'),
(N'C4_V9257168', N'4459676451'),
(N'C4_T9961063', N'0312342993'),
(N'C4_L9524515', N'0024757489'),
(N'C4_Q9947535', N'2480259668'),
(N'C4_T9892089', N'2451477016'),
(N'C4_N9945163', N'1289603669'),
(N'C4_W8785358', N'0322922720'),
(N'C4_J8388494', N'2002637903'),
(N'C4_V8165679', N'0081093829'),
(N'C4_T8579002', N'5289780335')
-- 2490371436 handled separately by UserId below
) AS v(ContractNumber, NationalCode)
)
UPDATE d
SET
d.ContractNumber = s.ContractNumber,
d.LastModified = SYSUTCDATETIME()
FROM CMS.DayaLoanContracts d
INNER JOIN Src s ON s.NationalCode = d.NationalCode
WHERE d.IsDeleted = 0;
PRINT CONCAT('Updated by NationalCode (unique): ', @@ROWCOUNT);
------------------------------------------------------------
-- 2) Shared NC 2490371436 — match by UserId + Excel activation date
------------------------------------------------------------
-- UserId 50 ← earlier activation 1404/8/24 → C4_D8815963
UPDATE CMS.DayaLoanContracts
SET
ContractNumber = N'C4_D8815963',
LastModified = SYSUTCDATETIME()
WHERE IsDeleted = 0
AND UserId = 50
AND NationalCode = N'2490371436';
PRINT CONCAT('Updated UserId=50 (C4_D8815963): ', @@ROWCOUNT);
-- UserId 51 ← later activation 1404/8/30 → C4_G8945289
IF EXISTS (
SELECT 1 FROM CMS.DayaLoanContracts
WHERE IsDeleted = 0 AND UserId = 51 AND NationalCode = N'2490371436'
)
BEGIN
UPDATE CMS.DayaLoanContracts
SET
ContractNumber = N'C4_G8945289',
LastModified = SYSUTCDATETIME()
WHERE IsDeleted = 0
AND UserId = 51
AND NationalCode = N'2490371436';
PRINT CONCAT('Updated UserId=51 (C4_G8945289): ', @@ROWCOUNT);
END
ELSE
BEGIN
INSERT INTO CMS.DayaLoanContracts
(
UserId, NationalCode, ContractNumber, Status, IsProcessed,
LastCheckDate, ProcessedDate, TransactionId,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
51,
N'2490371436',
N'C4_G8945289',
0, -- NotRequested (same as sibling row for UserId=50 in dump; adjust if needed)
0,
NULL,
NULL,
NULL,
SYSUTCDATETIME(),
NULL,
SYSUTCDATETIME(),
NULL,
0
);
PRINT 'Inserted DayaLoanContracts for UserId=51 (C4_G8945289).';
END
------------------------------------------------------------
-- Verify
------------------------------------------------------------
SELECT
d.Id,
d.UserId,
u.Mobile,
d.NationalCode,
d.ContractNumber,
d.Status,
d.IsProcessed
FROM CMS.DayaLoanContracts d
INNER JOIN CMS.Users u ON u.Id = d.UserId
WHERE d.IsDeleted = 0
AND d.NationalCode = N'2490371436'
ORDER BY d.UserId;
-- Uncomment to apply:
-- COMMIT;
ROLLBACK;
PRINT 'Rolled back (safety). Uncomment COMMIT and remove ROLLBACK to apply.';
@@ -0,0 +1,540 @@
/*
Staging fix: complete post-Daya processing for Excel rows with status «فعال شده»
that are NOT_PROCESSED or PARTIAL.
Classification:
DONE — credit + processed + Daya UPP
PARTIAL — HasReceivedDayaCredit OR IsProcessed OR existing «اعتبار دایا» Tx in wallet
NOT_PROCESSED — none of the above (will create Tx + ledger WH)
If ExistingDayaTxId found (wallet RefrenceId → Tx Description LIKE '%اعتبار دایا%'
OR Tx.RefId = ContractNumber): REUSE that Tx — do NOT create Transaction.
NO wallet Balance/DiscountBalance charge — UserWalletHistory is ledger-only when needed.
Special UserId mapping (shared NationalCode):
C4_D8815963 / 2490371436 → UserId 50
C4_G8945289 / 2490371436 → UserId 51
@Commit = 0 → dry-run (ROLLBACK)
@Commit = 1 → apply
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @Commit BIT = 0; -- << set 1 to apply
DECLARE @Now DATETIME2 = SYSUTCDATETIME();
DECLARE @PackageId BIGINT;
DECLARE @PackagePrice BIGINT;
DECLARE @DiscountMultiplier DECIMAL(18,4);
DECLARE @DiscountAmount BIGINT;
SELECT TOP (1)
@PackageId = Id,
@PackagePrice = Price,
@DiscountMultiplier = DiscountMultiplier
FROM CMS.Packages
WHERE IsDeleted = 0 AND IsBasePackage = 1
ORDER BY Id;
IF @PackageId IS NULL
THROW 50001, 'Base package not found.', 1;
SET @DiscountAmount = CAST(@PackagePrice * @DiscountMultiplier AS BIGINT);
PRINT CONCAT('PackageId=', @PackageId, ' Price=', @PackagePrice, ' Discount=', @DiscountAmount);
------------------------------------------------------------
-- Targets: Excel «فعال شده»
------------------------------------------------------------
DECLARE @Targets TABLE (
ContractNumber NVARCHAR(50) NOT NULL,
NationalCode NVARCHAR(20) NOT NULL,
UserIdHint BIGINT NULL -- explicit override when NC is shared
);
INSERT INTO @Targets (ContractNumber, NationalCode, UserIdHint) VALUES
(N'C4_H10706299', N'4060667961', NULL),
(N'C4_P9320383', N'2572729636', NULL),
(N'C4_Q9465813', N'2480017796', NULL),
(N'C4_B9348378', N'0452314488', NULL),
(N'C4_X9630520', N'0680164286', NULL),
(N'C4_B9813553', N'0055680364', NULL), -- may match DayaLoanContracts.NationalCode if Users.NC differs
(N'C4_M9994735', N'0793851572', NULL),
(N'C4_S9301452', N'0082242925', NULL),
(N'C4_T9961063', N'0312342993', NULL),
(N'C4_L9524515', N'0024757489', NULL),
(N'C4_Q9947535', N'2480259668', NULL),
(N'C4_T9892089', N'2451477016', NULL),
(N'C4_W8785358', N'0322922720', NULL),
(N'C4_D8815963', N'2490371436', 50), -- earlier activation → user 50
(N'C4_G8945289', N'2490371436', 51), -- later activation → user 51
(N'C4_J8388494', N'2002637903', NULL);
DECLARE @Work TABLE (
ContractNumber NVARCHAR(50),
NationalCode NVARCHAR(20),
UserId BIGINT,
DayaContractId BIGINT NULL,
HasCredit BIT,
IsProcessed BIT,
HasDayaUpp BIT,
ExistingDayaTxId BIGINT NULL, -- Tx از قبل با شرح «اعتبار دایا» / RefId قرارداد (شارژ شده)
Verdict NVARCHAR(20)
);
INSERT INTO @Work (ContractNumber, NationalCode, UserId, DayaContractId, HasCredit, IsProcessed, HasDayaUpp, ExistingDayaTxId, Verdict)
SELECT
t.ContractNumber,
t.NationalCode,
u.Id,
d.Id,
CAST(ISNULL(u.HasReceivedDayaCredit, 0) AS BIT),
CAST(ISNULL(d.IsProcessed, 0) AS BIT),
CAST(CASE WHEN EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
LEFT JOIN CMS.Transactions tx ON tx.Id = upp.TransactionId AND tx.IsDeleted = 0
WHERE upp.UserId = u.Id
AND upp.IsDeleted = 0
AND upp.PurchaseMethod = 1
AND (upp.TransactionId IS NULL OR tx.PaymentStatus = 0)
) THEN 1 ELSE 0 END AS BIT),
etx.TxId,
CASE
WHEN u.Id IS NULL THEN N'NO_USER'
WHEN ISNULL(u.HasReceivedDayaCredit, 0) = 1
AND ISNULL(d.IsProcessed, 0) = 1
AND EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
LEFT JOIN CMS.Transactions tx ON tx.Id = upp.TransactionId AND tx.IsDeleted = 0
WHERE upp.UserId = u.Id
AND upp.IsDeleted = 0
AND upp.PurchaseMethod = 1
AND (upp.TransactionId IS NULL OR tx.PaymentStatus = 0)
) THEN N'DONE'
-- قبلاً شارژ دایا در کیف/تراکنش هست ولی فلگ/UPP/قرارداد کامل نیست
WHEN etx.TxId IS NOT NULL THEN N'PARTIAL'
WHEN ISNULL(u.HasReceivedDayaCredit, 0) = 1
OR ISNULL(d.IsProcessed, 0) = 1 THEN N'PARTIAL'
ELSE N'NOT_PROCESSED'
END
FROM @Targets t
OUTER APPLY (
SELECT TOP (1) u0.*
FROM CMS.Users u0
WHERE u0.IsDeleted = 0
AND (
(t.UserIdHint IS NOT NULL AND u0.Id = t.UserIdHint)
OR (t.UserIdHint IS NULL AND u0.NationalCode = t.NationalCode)
OR (t.UserIdHint IS NULL AND EXISTS (
SELECT 1 FROM CMS.DayaLoanContracts dx
WHERE dx.UserId = u0.Id
AND dx.NationalCode = t.NationalCode
AND dx.IsDeleted = 0
))
)
ORDER BY
CASE WHEN t.UserIdHint IS NOT NULL AND u0.Id = t.UserIdHint THEN 0 ELSE 1 END,
CASE WHEN u0.NationalCode = t.NationalCode THEN 0 ELSE 1 END,
u0.Id
) u
OUTER APPLY (
SELECT TOP (1) d0.*
FROM CMS.DayaLoanContracts d0
WHERE d0.UserId = u.Id
AND (
d0.ContractNumber = t.ContractNumber
OR d0.NationalCode = t.NationalCode
OR d0.IsDeleted IN (0, 1)
)
ORDER BY
CASE WHEN d0.ContractNumber = t.ContractNumber THEN 0 ELSE 1 END,
CASE WHEN d0.IsDeleted = 0 THEN 0 ELSE 1 END,
d0.Id DESC
) d
OUTER APPLY (
-- تراکنش دایا که از قبل شارژ شده (مرجع کیف پول یا RefId قرارداد)
SELECT TOP (1) t0.Id AS TxId
FROM CMS.Transactions t0
WHERE t0.IsDeleted = 0
AND t0.PaymentStatus = 0
AND (
t0.RefId = t.ContractNumber
OR (
t0.Description LIKE N'%اعتبار دایا%'
AND EXISTS (
SELECT 1
FROM CMS.UserWallets w
INNER JOIN CMS.UserWalletHistories h ON h.WalletId = w.Id
WHERE w.UserId = u.Id
AND h.RefrenceId = t0.Id
)
)
)
ORDER BY
CASE WHEN t0.RefId = t.ContractNumber THEN 0 ELSE 1 END,
t0.Id DESC
) etx;
PRINT '=== Before ===';
SELECT Verdict, COUNT(*) AS Cnt FROM @Work GROUP BY Verdict;
SELECT ContractNumber, NationalCode, UserId, DayaContractId, HasCredit, IsProcessed, HasDayaUpp,
ExistingDayaTxId, Verdict
FROM @Work
ORDER BY Verdict, ContractNumber;
------------------------------------------------------------
-- PREVIEW: ledger actions (no wallet charge)
------------------------------------------------------------
PRINT '=== LEDGER PREVIEW (Tx + WH history-only — NO wallet charge) ===';
SELECT
w.ContractNumber,
w.NationalCode,
w.UserId,
w.Verdict,
w.HasCredit,
w.ExistingDayaTxId,
CASE
WHEN w.UserId IS NULL THEN N'SKIP_NO_USER'
WHEN w.ExistingDayaTxId IS NOT NULL THEN N'REUSE_TX_ENSURE_WH_UPP'
WHEN w.HasCredit = 0 THEN N'CREATE_TX_WH_UPP'
ELSE N'SET_FLAGS_ONLY_UPP'
END AS LedgerAction
FROM @Work w
WHERE w.Verdict IN (N'NOT_PROCESSED', N'PARTIAL')
ORDER BY LedgerAction, w.ContractNumber;
SELECT
CASE
WHEN w.UserId IS NULL THEN N'SKIP_NO_USER'
WHEN w.ExistingDayaTxId IS NOT NULL THEN N'REUSE_TX_ENSURE_WH_UPP'
WHEN w.HasCredit = 0 THEN N'CREATE_TX_WH_UPP'
ELSE N'SET_FLAGS_ONLY_UPP'
END AS LedgerAction,
COUNT(*) AS Cnt
FROM @Work w
WHERE w.Verdict IN (N'NOT_PROCESSED', N'PARTIAL')
GROUP BY
CASE
WHEN w.UserId IS NULL THEN N'SKIP_NO_USER'
WHEN w.ExistingDayaTxId IS NOT NULL THEN N'REUSE_TX_ENSURE_WH_UPP'
WHEN w.HasCredit = 0 THEN N'CREATE_TX_WH_UPP'
ELSE N'SET_FLAGS_ONLY_UPP'
END
ORDER BY LedgerAction;
DECLARE @PreviewCreateTxCount INT = (
SELECT COUNT(*)
FROM @Work w
WHERE w.Verdict IN (N'NOT_PROCESSED', N'PARTIAL')
AND w.UserId IS NOT NULL
AND w.ExistingDayaTxId IS NULL
AND w.HasCredit = 0
);
PRINT CONCAT('>>> CREATE_TX (no wallet charge) count = ', @PreviewCreateTxCount);
BEGIN TRAN;
DECLARE @ContractNumber NVARCHAR(50), @NationalCode NVARCHAR(20), @UserId BIGINT;
DECLARE @DayaContractId BIGINT, @HasCredit BIT, @IsProcessed BIT, @HasDayaUpp BIT, @Verdict NVARCHAR(20);
DECLARE @ExistingDayaTxId BIGINT;
DECLARE @TxId BIGINT, @WalletId BIGINT, @NetworkBalance BIGINT, @Action NVARCHAR(200);
DECLARE @CreateTxCount INT = 0;
DECLARE @ReuseCount INT = 0;
DECLARE @SkipFlagsOnlyCount INT = 0;
DECLARE @WhCreatedCount INT = 0;
DECLARE @Log TABLE (
ContractNumber NVARCHAR(50),
UserId BIGINT,
VerdictBefore NVARCHAR(20),
ActionTaken NVARCHAR(200),
TxId BIGINT NULL
);
DECLARE cur CURSOR LOCAL FAST_FORWARD FOR
SELECT ContractNumber, NationalCode, UserId, DayaContractId, HasCredit, IsProcessed, HasDayaUpp, ExistingDayaTxId, Verdict
FROM @Work
WHERE Verdict IN (N'NOT_PROCESSED', N'PARTIAL');
OPEN cur;
FETCH NEXT FROM cur INTO @ContractNumber, @NationalCode, @UserId, @DayaContractId, @HasCredit, @IsProcessed, @HasDayaUpp, @ExistingDayaTxId, @Verdict;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @TxId = NULL;
SET @Action = N'';
IF @UserId IS NULL
BEGIN
INSERT INTO @Log VALUES (@ContractNumber, NULL, @Verdict, N'SKIP NO_USER', NULL);
GOTO NextRow;
END
------------------------------------------------------------
-- Ensure / revive DayaLoanContract
------------------------------------------------------------
IF @DayaContractId IS NULL
BEGIN
INSERT INTO CMS.DayaLoanContracts
(
UserId, NationalCode, ContractNumber, Status, IsProcessed,
LastCheckDate, ProcessedDate, TransactionId,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@UserId, @NationalCode, @ContractNumber, 2, 0,
@Now, NULL, NULL,
@Now, NULL, @Now, NULL, 0
);
SET @DayaContractId = SCOPE_IDENTITY();
SET @Action = @Action + N'INSERT_CONTRACT;';
END
ELSE
BEGIN
UPDATE CMS.DayaLoanContracts
SET
IsDeleted = 0,
NationalCode = @NationalCode,
ContractNumber = @ContractNumber,
Status = 2, -- Received («فعال شده»)
LastCheckDate = @Now,
LastModified = @Now
WHERE Id = @DayaContractId;
SET @Action = @Action + N'UPDATE_CONTRACT;';
END
------------------------------------------------------------
-- Resolve Tx (reuse existing or create ledger Tx — NO wallet charge)
------------------------------------------------------------
IF @ExistingDayaTxId IS NOT NULL
BEGIN
SET @TxId = @ExistingDayaTxId;
SET @Action = @Action + CONCAT(N'REUSE_EXISTING_DAYA_TX=', @TxId, N';');
SET @ReuseCount = @ReuseCount + 1;
UPDATE CMS.Users
SET
PackagePurchaseMethod = 1,
HasReceivedDayaCredit = 1,
DayaCreditReceivedAt = COALESCE(DayaCreditReceivedAt, @Now),
LastModified = @Now
WHERE Id = @UserId;
SET @HasCredit = 1;
SET @Action = @Action + N'SET_USER_FLAGS;';
END
ELSE IF @HasCredit = 0
BEGIN
INSERT INTO CMS.Transactions
(
Amount, Description, PaymentStatus, PaymentDate, RefId, Type,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@PackagePrice,
CONCAT(N'دریافت اعتبار دایا - قرارداد ', @ContractNumber),
0,
@Now,
@ContractNumber,
2,
@Now, NULL, @Now, NULL, 0
);
SET @TxId = SCOPE_IDENTITY();
SET @CreateTxCount = @CreateTxCount + 1;
UPDATE CMS.Users
SET
HasReceivedDayaCredit = 1,
DayaCreditReceivedAt = @Now,
PackagePurchaseMethod = 1,
LastModified = @Now
WHERE Id = @UserId;
SET @HasCredit = 1;
SET @Action = @Action + N'CREATE_TX+USER_FLAGS;';
END
ELSE
BEGIN
UPDATE CMS.Users
SET
PackagePurchaseMethod = 1,
HasReceivedDayaCredit = 1,
DayaCreditReceivedAt = COALESCE(DayaCreditReceivedAt, @Now),
LastModified = @Now
WHERE Id = @UserId;
SET @SkipFlagsOnlyCount = @SkipFlagsOnlyCount + 1;
SET @Action = @Action + N'SET_FLAGS_ONLY;';
END
------------------------------------------------------------
-- UserWalletHistory for Tx (ledger-only — NO Balance/Discount charge)
------------------------------------------------------------
IF @TxId IS NOT NULL
BEGIN
SELECT @WalletId = Id, @NetworkBalance = COALESCE(NetworkBalance, 0)
FROM CMS.UserWallets
WHERE UserId = @UserId AND IsDeleted = 0;
IF @WalletId IS NULL
BEGIN
INSERT INTO CMS.UserWallets
(UserId, Balance, NetworkBalance, DiscountBalance, WalletMode, Created, LastModified, IsDeleted)
VALUES (@UserId, 0, 0, 0, 0, @Now, @Now, 0);
SET @WalletId = SCOPE_IDENTITY();
SET @NetworkBalance = 0;
END
IF NOT EXISTS (
SELECT 1
FROM CMS.UserWalletHistories h
WHERE h.WalletId = @WalletId
AND h.IsDeleted = 0
AND h.RefrenceId = @TxId
)
BEGIN
INSERT INTO CMS.UserWalletHistories
(
WalletId, CurrentBalance, ChangeValue, CurrentNetworkBalance, ChangeNerworkValue,
CurrentDiscountBalance, ChangeDiscountValue, IsIncrease, RefrenceId, PackageId,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@WalletId, 0, @PackagePrice, @NetworkBalance, 0,
0, @DiscountAmount, 1, @TxId, @PackageId,
@Now, NULL, @Now, NULL, 0
);
SET @WhCreatedCount = @WhCreatedCount + 1;
SET @Action = @Action + N'INSERT_WH_HISTORY;';
END
END
------------------------------------------------------------
-- Ensure successful Daya UPP (no duplicate if already exists)
------------------------------------------------------------
IF @HasDayaUpp = 0
BEGIN
IF @TxId IS NULL
BEGIN
-- فقط لجر؛ بدون شارژ کیف (نباید برای کسی که Tx دایا دارد به اینجا برسد)
INSERT INTO CMS.Transactions
(
Amount, Description, PaymentStatus, PaymentDate, RefId, Type,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@PackagePrice,
CONCAT(N'لجر UPP دایا (بدون شارژ مجدد) - قرارداد ', @ContractNumber),
0,
@Now,
CONCAT(N'UPP-DAYA-FIX-', @ContractNumber),
2,
@Now, NULL, @Now, NULL, 0
);
SET @TxId = SCOPE_IDENTITY();
SET @Action = @Action + N'LEDGER_TX;';
END
INSERT INTO CMS.UserPackagePurchases
(
UserId, PackageId, PurchaseMethod, PurchasedAt, Amount,
OrderId, TransactionId, Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@UserId, @PackageId, 1, @Now, @PackagePrice,
NULL, @TxId, @Now, NULL, @Now, NULL, 0
);
SET @Action = @Action + N'INSERT_UPP;';
END
------------------------------------------------------------
-- Mark contract processed
------------------------------------------------------------
UPDATE CMS.DayaLoanContracts
SET
IsProcessed = 1,
ProcessedDate = COALESCE(ProcessedDate, @Now),
TransactionId = COALESCE(TransactionId, @TxId),
Status = 2,
ContractNumber = @ContractNumber,
IsDeleted = 0,
LastModified = @Now
WHERE Id = @DayaContractId;
SET @Action = @Action + N'MARK_PROCESSED;';
INSERT INTO @Log VALUES (@ContractNumber, @UserId, @Verdict, @Action, @TxId);
NextRow:
FETCH NEXT FROM cur INTO @ContractNumber, @NationalCode, @UserId, @DayaContractId, @HasCredit, @IsProcessed, @HasDayaUpp, @ExistingDayaTxId, @Verdict;
END
CLOSE cur;
DEALLOCATE cur;
PRINT '=== LEDGER SUMMARY (no wallet charge) ===';
PRINT CONCAT('CREATE_TX count = ', @CreateTxCount);
PRINT CONCAT('REUSE_TX count = ', @ReuseCount);
PRINT CONCAT('SET_FLAGS_ONLY count = ', @SkipFlagsOnlyCount);
PRINT CONCAT('WH history created = ', @WhCreatedCount);
PRINT '=== Actions ===';
SELECT * FROM @Log ORDER BY VerdictBefore, ContractNumber;
-- Re-score after changes
PRINT '=== After (same targets) ===';
SELECT
t.ContractNumber,
t.NationalCode,
u.Id AS UserId,
u.HasReceivedDayaCredit,
d.IsProcessed,
d.ContractNumber AS DbContractNumber,
d.Status,
CASE WHEN EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
LEFT JOIN CMS.Transactions tx ON tx.Id = upp.TransactionId AND tx.IsDeleted = 0
WHERE upp.UserId = u.Id AND upp.IsDeleted = 0 AND upp.PurchaseMethod = 1
AND (upp.TransactionId IS NULL OR tx.PaymentStatus = 0)
) THEN 1 ELSE 0 END AS HasDayaUpp,
CASE
WHEN u.Id IS NULL THEN N'NO_USER'
WHEN u.HasReceivedDayaCredit = 1 AND d.IsProcessed = 1
AND EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
LEFT JOIN CMS.Transactions tx ON tx.Id = upp.TransactionId AND tx.IsDeleted = 0
WHERE upp.UserId = u.Id AND upp.IsDeleted = 0 AND upp.PurchaseMethod = 1
AND (upp.TransactionId IS NULL OR tx.PaymentStatus = 0)
) THEN N'DONE'
WHEN u.HasReceivedDayaCredit = 1 OR d.IsProcessed = 1 THEN N'PARTIAL'
ELSE N'NOT_PROCESSED'
END AS Verdict
FROM @Targets t
LEFT JOIN CMS.Users u ON u.IsDeleted = 0 AND (
(t.UserIdHint IS NOT NULL AND u.Id = t.UserIdHint) OR (t.UserIdHint IS NULL AND u.NationalCode = t.NationalCode)
)
LEFT JOIN CMS.DayaLoanContracts d ON d.IsDeleted = 0 AND d.UserId = u.Id
AND (d.ContractNumber = t.ContractNumber OR d.NationalCode = t.NationalCode)
ORDER BY Verdict, t.ContractNumber;
IF @Commit = 1
BEGIN
COMMIT;
PRINT 'COMMITTED.';
END
ELSE
BEGIN
ROLLBACK;
PRINT 'Dry-run ROLLBACK. Set @Commit = 1 to apply.';
END
@@ -0,0 +1,547 @@
/*
Fix users wrongly marked DayaLoan who were manually charged (RefId LIKE 'manualy%').
Scope: ONLY users linked to manualy transactions — NOT the full 47-user WrongDaya list.
Real Daya users (RefId LIKE 'C4%') are excluded automatically.
Actions:
- PurchaseMethod 1→3 on Users / Club / Cycle / UPP
- Clear HasReceivedDayaCredit / DayaCreditReceivedAt
- Soft-delete stub DayaLoanContracts (unprocessed, no Tx)
- Fix broken WH.RefrenceId where CreatedBy hint points to existing manualy Tx
- Create missing UPP Manual(3) reusing existing manualy Tx (never create new Tx)
- Create missing WH rows only when Tx exists but no WH links to it (history-only, no charge)
RefrenceId=0 rule:
Tx is resolved ONLY via numeric CreatedBy hint when that Tx exists with RefId manualy%.
Mobile-only or manualy{N} suffix alone is NOT used to assume a Tx exists.
PackagePurchaseMethod: None=0, DayaLoan=1, DirectPurchase=2, Manual=3
Defaults to ROLLBACK. Uncomment COMMIT after verifying PRINT output.
Run 10-Diagnose_Manualy_Transaction_Map.sql first.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRAN;
DECLARE @Now DATETIME2 = SYSUTCDATETIME();
DECLARE @BasePackageId BIGINT;
DECLARE @BasePackagePrice BIGINT;
SELECT TOP (1)
@BasePackageId = Id,
@BasePackagePrice = Price
FROM CMS.Packages
WHERE IsDeleted = 0 AND IsBasePackage = 1
ORDER BY Id;
IF @BasePackageId IS NULL
THROW 50001, 'Base package not found.', 1;
------------------------------------------------------------
-- Scope: users linked to manualy transactions
-- (duplicate WH→Tx links exist from migration; rank by mobile match + charge amount + date)
------------------------------------------------------------
DECLARE @ManualyUsers TABLE (
UserId BIGINT NOT NULL PRIMARY KEY,
ManualyTxId BIGINT NULL,
LinkSource NVARCHAR(50) NULL
);
SELECT t.Id AS TxId, t.RefId, t.Amount, t.PaymentDate, t.CreatedBy, t.LastModifiedBy
INTO #ManualyTx
FROM CMS.Transactions t
WHERE t.IsDeleted = 0 AND t.RefId LIKE N'manualy%';
SELECT
mt.TxId,
w.UserId,
h.Id AS WhId,
h.ChangeValue,
CASE
WHEN NULLIF(LTRIM(RTRIM(mt.CreatedBy)), N'') LIKE N'09%'
AND u.Mobile = NULLIF(LTRIM(RTRIM(mt.CreatedBy)), N'') THEN 1
WHEN NULLIF(LTRIM(RTRIM(mt.LastModifiedBy)), N'') LIKE N'09%'
AND u.Mobile = NULLIF(LTRIM(RTRIM(mt.LastModifiedBy)), N'') THEN 1
ELSE 0
END AS MobileMatch,
CASE WHEN h.ChangeValue >= mt.Amount THEN 1 ELSE 0 END AS FullCharge,
ABS(DATEDIFF(SECOND, h.Created, mt.PaymentDate)) AS TimeDeltaSec
INTO #WhLinks
FROM #ManualyTx mt
INNER JOIN CMS.UserWalletHistories h ON h.RefrenceId = mt.TxId AND h.IsDeleted = 0
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
INNER JOIN CMS.Users u ON u.Id = w.UserId AND u.IsDeleted = 0;
SELECT UserId, TxId, WhId, MobileMatch, FullCharge,
ROW_NUMBER() OVER (
PARTITION BY TxId
ORDER BY MobileMatch DESC, FullCharge DESC, TimeDeltaSec ASC, WhId ASC
) AS rn
INTO #BestWhPerTx
FROM #WhLinks;
INSERT INTO @ManualyUsers (UserId, ManualyTxId, LinkSource)
SELECT b.UserId, b.TxId, N'WH_REF_BEST'
FROM #BestWhPerTx b
WHERE b.rn = 1
AND (b.MobileMatch = 1 OR b.FullCharge = 1);
INSERT INTO @ManualyUsers (UserId, ManualyTxId, LinkSource)
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%'
INNER JOIN CMS.Users u ON u.Id = w.UserId AND u.IsDeleted = 0
WHERE h.IsDeleted = 0
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0)
AND TRY_CAST(h.CreatedBy AS BIGINT) > 0
AND (
NULLIF(LTRIM(RTRIM(t.CreatedBy)), N'') NOT LIKE N'09%'
OR u.Mobile IN (
NULLIF(LTRIM(RTRIM(t.CreatedBy)), N''),
NULLIF(LTRIM(RTRIM(t.LastModifiedBy)), N'')
)
)
AND NOT EXISTS (SELECT 1 FROM @ManualyUsers m WHERE m.UserId = w.UserId);
INSERT INTO @ManualyUsers (UserId, ManualyTxId, LinkSource)
SELECT DISTINCT u.Id, t.Id, N'TX_MOBILE'
FROM CMS.Transactions t
INNER JOIN CMS.Users u ON u.IsDeleted = 0
AND u.Mobile IN (
NULLIF(LTRIM(RTRIM(t.CreatedBy)), N''),
NULLIF(LTRIM(RTRIM(t.LastModifiedBy)), N'')
)
WHERE t.IsDeleted = 0 AND t.RefId LIKE N'manualy%'
AND NOT EXISTS (SELECT 1 FROM @ManualyUsers m WHERE m.UserId = u.Id);
-- Txs without mobile on row: keep best full-charge WH only (early batch 23-31)
INSERT INTO @ManualyUsers (UserId, ManualyTxId, LinkSource)
SELECT b.UserId, b.TxId, N'WH_REF_NO_MOBILE'
FROM #BestWhPerTx b
INNER JOIN #ManualyTx mt ON mt.TxId = b.TxId
WHERE b.rn = 1
AND b.FullCharge = 1
AND (NULLIF(LTRIM(RTRIM(mt.CreatedBy)), N'') IS NULL OR NULLIF(LTRIM(RTRIM(mt.CreatedBy)), N'') NOT LIKE N'09%')
AND (NULLIF(LTRIM(RTRIM(mt.LastModifiedBy)), N'') IS NULL OR NULLIF(LTRIM(RTRIM(mt.LastModifiedBy)), N'') NOT LIKE N'09%')
AND NOT EXISTS (SELECT 1 FROM @ManualyUsers m WHERE m.UserId = b.UserId);
DROP TABLE #BestWhPerTx;
DROP TABLE #WhLinks;
DROP TABLE #ManualyTx;
DECLARE @ScopeCount INT = (SELECT COUNT(*) FROM @ManualyUsers);
PRINT CONCAT('manualy scope users: ', @ScopeCount);
PRINT CONCAT('BasePackageId=', @BasePackageId, ' Price=', @BasePackagePrice);
SELECT mu.UserId, u.Mobile, u.FirstName, u.LastName,
u.PackagePurchaseMethod, mu.ManualyTxId, mu.LinkSource
FROM @ManualyUsers mu
INNER JOIN CMS.Users u ON u.Id = mu.UserId AND u.IsDeleted = 0
ORDER BY mu.UserId;
------------------------------------------------------------
-- Preview
------------------------------------------------------------
SELECT 'Users with manualy Tx resolved' AS CheckName, COUNT(*) AS Cnt
FROM @ManualyUsers WHERE ManualyTxId IS NOT NULL
UNION ALL
SELECT 'Users in scope without resolved Tx', COUNT(*)
FROM @ManualyUsers WHERE ManualyTxId IS NULL
UNION ALL
SELECT 'Broken WH rows fixable via CreatedBy hint', COUNT(*)
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
INNER JOIN @ManualyUsers mu ON mu.UserId = w.UserId
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 ALL
SELECT 'Missing UPP in scope', COUNT(*)
FROM @ManualyUsers mu
WHERE NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = mu.UserId AND upp.IsDeleted = 0
);
------------------------------------------------------------
-- 1) Fix broken WH.RefrenceId (CreatedBy hint → existing manualy Tx)
------------------------------------------------------------
UPDATE h
SET
h.RefrenceId = t.Id,
h.LastModified = @Now
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
INNER JOIN @ManualyUsers mu ON mu.UserId = w.UserId
INNER JOIN CMS.Transactions t ON t.Id = TRY_CAST(h.CreatedBy AS BIGINT)
AND t.IsDeleted = 0
AND t.RefId LIKE N'manualy%'
AND t.PaymentStatus = 0
WHERE h.IsDeleted = 0
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0)
AND TRY_CAST(h.CreatedBy AS BIGINT) > 0;
PRINT CONCAT('WalletHistory RefrenceId fixed: ', @@ROWCOUNT);
------------------------------------------------------------
-- 2) Users — DayaLoan → Manual
------------------------------------------------------------
UPDATE u
SET
u.PackagePurchaseMethod = 3,
u.HasReceivedDayaCredit = 0,
u.DayaCreditReceivedAt = NULL,
u.LastModified = @Now
FROM CMS.Users u
INNER JOIN @ManualyUsers mu ON mu.UserId = u.Id
WHERE u.IsDeleted = 0
AND (
u.PackagePurchaseMethod = 1
OR u.HasReceivedDayaCredit = 1
OR u.DayaCreditReceivedAt IS NOT NULL
);
PRINT CONCAT('Users updated: ', @@ROWCOUNT);
------------------------------------------------------------
-- 3) ClubMemberships
------------------------------------------------------------
UPDATE m
SET m.PurchaseMethod = 3, m.LastModified = @Now
FROM CMS.ClubMemberships m
INNER JOIN @ManualyUsers mu ON mu.UserId = m.UserId
WHERE m.IsDeleted = 0 AND m.PurchaseMethod IN (0, 1);
PRINT CONCAT('ClubMemberships updated: ', @@ROWCOUNT);
------------------------------------------------------------
-- 4) ClubMembershipCycles
------------------------------------------------------------
UPDATE c
SET c.PurchaseMethod = 3, c.LastModified = @Now
FROM CMS.ClubMembershipCycles c
INNER JOIN @ManualyUsers mu ON mu.UserId = c.UserId
WHERE c.IsDeleted = 0 AND c.PurchaseMethod IN (0, 1);
PRINT CONCAT('ClubMembershipCycles updated: ', @@ROWCOUNT);
------------------------------------------------------------
-- 5) UserPackagePurchases (existing rows Daya → Manual)
------------------------------------------------------------
UPDATE upp
SET upp.PurchaseMethod = 3, upp.LastModified = @Now
FROM CMS.UserPackagePurchases upp
INNER JOIN @ManualyUsers mu ON mu.UserId = upp.UserId
WHERE upp.IsDeleted = 0 AND upp.PurchaseMethod = 1;
PRINT CONCAT('UserPackagePurchases updated: ', @@ROWCOUNT);
------------------------------------------------------------
-- 6) Soft-delete stub DayaLoanContracts
------------------------------------------------------------
UPDATE d
SET d.IsDeleted = 1, d.LastModified = @Now
FROM CMS.DayaLoanContracts d
INNER JOIN @ManualyUsers mu ON mu.UserId = d.UserId
WHERE d.IsDeleted = 0
AND d.IsProcessed = 0
AND d.TransactionId IS NULL
AND d.ProcessedDate IS NULL;
PRINT CONCAT('DayaLoanContracts soft-deleted (stubs): ', @@ROWCOUNT);
------------------------------------------------------------
-- 7) Resolve best manualy Tx per user (post WH fix)
------------------------------------------------------------
;WITH TxCandidates AS (
SELECT mu.UserId, t.Id AS TxId, N'WH_REF' AS Src, 1 AS Pri
FROM @ManualyUsers mu
INNER JOIN CMS.UserWallets w ON w.UserId = mu.UserId AND w.IsDeleted = 0
INNER JOIN CMS.UserWalletHistories h ON h.WalletId = w.Id AND h.IsDeleted = 0
INNER JOIN CMS.Transactions t ON t.Id = h.RefrenceId
AND t.IsDeleted = 0 AND t.RefId LIKE N'manualy%' AND t.PaymentStatus = 0
UNION ALL
SELECT mu.UserId, t.Id, N'WH_HINT', 2
FROM @ManualyUsers mu
INNER JOIN CMS.UserWallets w ON w.UserId = mu.UserId AND w.IsDeleted = 0
INNER JOIN CMS.UserWalletHistories h ON h.WalletId = w.Id AND h.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%' AND t.PaymentStatus = 0
WHERE (h.RefrenceId IS NULL OR h.RefrenceId = 0)
AND TRY_CAST(h.CreatedBy AS BIGINT) > 0
UNION ALL
SELECT mu.UserId, t.Id, N'TX_MOBILE', 3
FROM @ManualyUsers mu
INNER JOIN CMS.Users u ON u.Id = mu.UserId AND u.IsDeleted = 0
INNER JOIN CMS.Transactions t ON t.IsDeleted = 0
AND t.RefId LIKE N'manualy%' AND t.PaymentStatus = 0
AND u.Mobile IN (
NULLIF(LTRIM(RTRIM(t.CreatedBy)), N''),
NULLIF(LTRIM(RTRIM(t.LastModifiedBy)), N'')
)
UNION ALL
SELECT mu.UserId, mu.ManualyTxId, mu.LinkSource, 4
FROM @ManualyUsers mu
WHERE mu.ManualyTxId IS NOT NULL
),
BestTx AS (
SELECT UserId, TxId, Src,
ROW_NUMBER() OVER (PARTITION BY UserId ORDER BY Pri, TxId DESC) AS rn
FROM TxCandidates
WHERE TxId IS NOT NULL
)
SELECT
mu.UserId,
bt.TxId,
bt.Src AS TxSource,
COALESCE(NULLIF(cm.LastPackageId, 0), NULLIF(cm.FirstPackageId, 0), @BasePackageId) AS PackageId,
COALESCE(t.Amount, @BasePackagePrice) AS Amount,
COALESCE(t.PaymentDate, cm.ActivatedAt, @Now) AS PurchasedAt
INTO #ManualyUppPlan
FROM @ManualyUsers mu
LEFT JOIN BestTx bt ON bt.UserId = mu.UserId AND bt.rn = 1
LEFT JOIN CMS.Transactions t ON t.Id = bt.TxId AND t.IsDeleted = 0
LEFT JOIN CMS.ClubMemberships cm ON cm.UserId = mu.UserId AND cm.IsDeleted = 0
WHERE NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = mu.UserId AND upp.IsDeleted = 0
);
DECLARE @UppPlanCount INT = (SELECT COUNT(*) FROM #ManualyUppPlan);
PRINT CONCAT('UPP to create: ', @UppPlanCount);
SELECT UserId, TxId, TxSource, PackageId, Amount, PurchasedAt,
CASE WHEN TxId IS NULL THEN N'SKIP_NO_MANUALY_TX' ELSE N'CREATE_UPP' END AS PlanAction
FROM #ManualyUppPlan
ORDER BY UserId;
DECLARE @UserId BIGINT;
DECLARE @TxId BIGINT;
DECLARE @PackageId BIGINT;
DECLARE @Amount BIGINT;
DECLARE @PurchasedAt DATETIME2;
DECLARE @CreatedUpp INT = 0;
DECLARE @SkippedNoTx INT = 0;
DECLARE curUpp CURSOR LOCAL FAST_FORWARD FOR
SELECT UserId, TxId, PackageId, Amount, PurchasedAt
FROM #ManualyUppPlan;
OPEN curUpp;
FETCH NEXT FROM curUpp INTO @UserId, @TxId, @PackageId, @Amount, @PurchasedAt;
WHILE @@FETCH_STATUS = 0
BEGIN
IF @TxId IS NULL OR @PackageId IS NULL OR @Amount IS NULL OR @Amount <= 0
BEGIN
SET @SkippedNoTx = @SkippedNoTx + 1;
END
ELSE
BEGIN
INSERT INTO CMS.UserPackagePurchases
(
UserId, PackageId, PurchaseMethod, PurchasedAt, Amount,
OrderId, TransactionId, Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@UserId, @PackageId, 3,
@PurchasedAt, @Amount,
NULL, @TxId, @PurchasedAt, NULL, @PurchasedAt, NULL, 0
);
SET @CreatedUpp = @CreatedUpp + 1;
END
FETCH NEXT FROM curUpp INTO @UserId, @TxId, @PackageId, @Amount, @PurchasedAt;
END
CLOSE curUpp;
DEALLOCATE curUpp;
DROP TABLE #ManualyUppPlan;
PRINT CONCAT('UPP created: ', @CreatedUpp, ' | Skipped (no manualy Tx): ', @SkippedNoTx);
------------------------------------------------------------
-- 8) Missing WH for resolved manualy Tx (history-only, no wallet charge)
------------------------------------------------------------
;WITH TxCandidates AS (
SELECT mu.UserId, t.Id AS TxId, 1 AS Pri
FROM @ManualyUsers mu
INNER JOIN CMS.UserWallets w ON w.UserId = mu.UserId AND w.IsDeleted = 0
INNER JOIN CMS.UserWalletHistories h ON h.WalletId = w.Id AND h.IsDeleted = 0
INNER JOIN CMS.Transactions t ON t.Id = h.RefrenceId
AND t.IsDeleted = 0 AND t.RefId LIKE N'manualy%'
UNION ALL
SELECT mu.UserId, t.Id, 2
FROM @ManualyUsers mu
INNER JOIN CMS.Users u ON u.Id = mu.UserId AND u.IsDeleted = 0
INNER JOIN CMS.Transactions t ON t.IsDeleted = 0 AND t.RefId LIKE N'manualy%'
AND u.Mobile IN (
NULLIF(LTRIM(RTRIM(t.CreatedBy)), N''),
NULLIF(LTRIM(RTRIM(t.LastModifiedBy)), N'')
)
),
BestTx AS (
SELECT UserId, TxId,
ROW_NUMBER() OVER (PARTITION BY UserId ORDER BY Pri, TxId DESC) AS rn
FROM TxCandidates
)
SELECT
bt.UserId,
bt.TxId,
w.Id AS WalletId,
COALESCE(t.Amount, @BasePackagePrice) AS Amount,
COALESCE(t.PaymentDate, @Now) AS HistoryAt,
COALESCE(
NULLIF(cm.LastPackageId, 0),
NULLIF(cm.FirstPackageId, 0),
@BasePackageId
) AS PackageId,
CAST(ROUND(COALESCE(t.Amount, @BasePackagePrice) * COALESCE(p.DiscountMultiplier, 2.0), 0) AS BIGINT) AS DiscountAmount,
COALESCE(w.NetworkBalance, 0) AS NetworkBalance
INTO #WhToCreate
FROM BestTx bt
INNER JOIN CMS.Transactions t ON t.Id = bt.TxId AND t.IsDeleted = 0
LEFT JOIN CMS.UserWallets w ON w.UserId = bt.UserId AND w.IsDeleted = 0
LEFT JOIN CMS.ClubMemberships cm ON cm.UserId = bt.UserId AND cm.IsDeleted = 0
LEFT JOIN CMS.Packages p ON p.Id = COALESCE(
NULLIF(cm.LastPackageId, 0),
NULLIF(cm.FirstPackageId, 0),
@BasePackageId
) AND p.IsDeleted = 0
WHERE bt.rn = 1
AND NOT EXISTS (
SELECT 1
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets wh ON wh.Id = h.WalletId AND wh.IsDeleted = 0
WHERE wh.UserId = bt.UserId
AND h.IsDeleted = 0
AND h.RefrenceId = bt.TxId
);
DECLARE @WhPlanCount INT = (SELECT COUNT(*) FROM #WhToCreate);
PRINT CONCAT('WalletHistory to create (no charge): ', @WhPlanCount);
SELECT UserId, TxId, WalletId, Amount, HistoryAt, N'CREATE_HISTORY_ONLY' AS PlanAction
FROM #WhToCreate
ORDER BY UserId;
DECLARE @WalletId BIGINT;
DECLARE @DiscountAmount BIGINT;
DECLARE @HistoryAt DATETIME2;
DECLARE @NetworkBalance BIGINT;
DECLARE @CreatedWh INT = 0;
DECLARE curWh CURSOR LOCAL FAST_FORWARD FOR
SELECT UserId, TxId, WalletId, Amount, PackageId, DiscountAmount, HistoryAt, NetworkBalance
FROM #WhToCreate;
OPEN curWh;
FETCH NEXT FROM curWh INTO
@UserId, @TxId, @WalletId, @Amount, @PackageId, @DiscountAmount, @HistoryAt, @NetworkBalance;
WHILE @@FETCH_STATUS = 0
BEGIN
IF @WalletId IS NULL
BEGIN
INSERT INTO CMS.UserWallets
(UserId, Balance, NetworkBalance, DiscountBalance, WalletMode, Created, LastModified, IsDeleted)
VALUES (@UserId, 0, 0, 0, 0, @HistoryAt, @HistoryAt, 0);
SET @WalletId = SCOPE_IDENTITY();
SET @NetworkBalance = 0;
END
INSERT INTO CMS.UserWalletHistories
(
WalletId, CurrentBalance, ChangeValue, CurrentNetworkBalance, ChangeNerworkValue,
CurrentDiscountBalance, ChangeDiscountValue, IsIncrease, RefrenceId, PackageId,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@WalletId, 0, @Amount, @NetworkBalance, 0,
0, @DiscountAmount, 1, @TxId, @PackageId,
@HistoryAt, NULL, @HistoryAt, NULL, 0
);
SET @CreatedWh = @CreatedWh + 1;
FETCH NEXT FROM curWh INTO
@UserId, @TxId, @WalletId, @Amount, @PackageId, @DiscountAmount, @HistoryAt, @NetworkBalance;
END
CLOSE curWh;
DEALLOCATE curWh;
DROP TABLE #WhToCreate;
PRINT CONCAT('WalletHistory created (no charge): ', @CreatedWh);
------------------------------------------------------------
-- Verify
------------------------------------------------------------
SELECT 'Users still Daya in scope' AS CheckName, COUNT(*) AS Cnt
FROM CMS.Users u
INNER JOIN @ManualyUsers mu ON mu.UserId = u.Id
WHERE u.IsDeleted = 0 AND u.PackagePurchaseMethod = 1
UNION ALL
SELECT 'Still missing UPP in scope', COUNT(*)
FROM @ManualyUsers mu
WHERE NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = mu.UserId AND upp.IsDeleted = 0
)
UNION ALL
SELECT 'Broken WH Ref=0 with valid hint remaining', COUNT(*)
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
INNER JOIN @ManualyUsers mu ON mu.UserId = w.UserId
WHERE h.IsDeleted = 0
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0)
AND TRY_CAST(h.CreatedBy AS BIGINT) > 0
AND EXISTS (
SELECT 1 FROM CMS.Transactions t
WHERE t.Id = TRY_CAST(h.CreatedBy AS BIGINT)
AND t.IsDeleted = 0 AND t.RefId LIKE N'manualy%'
)
UNION ALL
SELECT 'Active stub Daya contracts in scope', COUNT(*)
FROM CMS.DayaLoanContracts d
INNER JOIN @ManualyUsers mu ON mu.UserId = d.UserId
WHERE d.IsDeleted = 0
AND d.IsProcessed = 0
AND d.TransactionId IS NULL
AND d.ProcessedDate IS NULL;
SELECT u.Id AS UserId, u.Mobile, u.FirstName, u.LastName,
u.PackagePurchaseMethod,
(SELECT COUNT(*) FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = u.Id AND upp.IsDeleted = 0) AS UppCnt,
(SELECT COUNT(*) FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId
WHERE w.UserId = u.Id AND h.IsDeleted = 0) AS WhCnt
FROM CMS.Users u
INNER JOIN @ManualyUsers mu ON mu.UserId = u.Id
WHERE u.IsDeleted = 0
ORDER BY u.Id;
ROLLBACK;
-- COMMIT;
PRINT 'Done (rolled back by default).';
@@ -0,0 +1,295 @@
/*
PHASE 1 — Create UserPackagePurchases (Manual=3) only.
Scope: 17 users with verified manualy charge — both WalletHistory AND Transaction exist
and WH.RefrenceId already points to the Tx.
Hardcoded mapping (from UserWalletHistories.csv + Transactions.csv):
UserId WH Tx
------ ---- ----
75 102 23
93 103 24
96 104 25
99 105 26
47 106 28
43 107 29
45 108 30
52 122 31
116 138 42
115 139 43
119 140 44
113 141 45
103 142 46
170 292 107
171 293 108
172 294 109
175 295 110
Does NOT:
- change Users / Club / Cycle PurchaseMethod
- create or modify Transactions or WalletHistory
- re-charge wallet
PackagePurchaseMethod on UPP: Manual = 3
Defaults to ROLLBACK. Uncomment COMMIT after reviewing preview + counts.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRAN;
DECLARE @Now DATETIME2 = SYSUTCDATETIME();
DECLARE @BasePackageId BIGINT;
DECLARE @BasePackagePrice BIGINT;
SELECT TOP (1)
@BasePackageId = Id,
@BasePackagePrice = Price
FROM CMS.Packages
WHERE IsDeleted = 0 AND IsBasePackage = 1
ORDER BY Id;
IF @BasePackageId IS NULL
THROW 50001, 'Base package not found.', 1;
------------------------------------------------------------
-- Expected mapping
------------------------------------------------------------
DECLARE @Phase1 TABLE (
UserId BIGINT NOT NULL,
WalletHistoryId BIGINT NOT NULL,
TransactionId BIGINT NOT NULL,
PRIMARY KEY (UserId)
);
INSERT INTO @Phase1 (UserId, WalletHistoryId, TransactionId) VALUES
(75, 102, 23),
(93, 103, 24),
(96, 104, 25),
(99, 105, 26),
(47, 106, 28),
(43, 107, 29),
(45, 108, 30),
(52, 122, 31),
(116, 138, 42),
(115, 139, 43),
(119, 140, 44),
(113, 141, 45),
(103, 142, 46),
(170, 292, 107),
(171, 293, 108),
(172, 294, 109),
(175, 295, 110);
DECLARE @Phase1Count INT = (SELECT COUNT(*) FROM @Phase1);
PRINT CONCAT('Phase1 expected rows: ', @Phase1Count);
------------------------------------------------------------
-- Validate live DB matches expected mapping
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile,
u.FirstName,
u.LastName,
p.WalletHistoryId AS ExpectedWhId,
p.TransactionId AS ExpectedTxId,
h.Id AS ActualWhId,
h.RefrenceId AS ActualWhRefrenceId,
h.ChangeValue AS WhChangeValue,
t.Id AS ActualTxId,
t.RefId AS TxRefId,
t.Amount AS TxAmount,
t.PaymentStatus,
w.UserId AS WhWalletUserId,
CASE
WHEN u.Id IS NULL THEN N'USER_MISSING'
WHEN h.Id IS NULL THEN N'WH_MISSING'
WHEN t.Id IS NULL THEN N'TX_MISSING'
WHEN h.RefrenceId <> p.TransactionId THEN N'WH_REF_MISMATCH'
WHEN w.UserId <> p.UserId THEN N'WH_WALLET_USER_MISMATCH'
WHEN t.RefId NOT LIKE N'manualy%' THEN N'TX_NOT_MANUALY'
WHEN t.PaymentStatus <> 0 THEN N'TX_NOT_SUCCESS'
ELSE N'OK'
END AS ValidationStatus
FROM @Phase1 p
LEFT JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
LEFT JOIN CMS.UserWalletHistories h ON h.Id = p.WalletHistoryId AND h.IsDeleted = 0
LEFT JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
LEFT JOIN CMS.Transactions t ON t.Id = p.TransactionId AND t.IsDeleted = 0
ORDER BY p.UserId;
DECLARE @BadValidation INT = (
SELECT COUNT(*)
FROM @Phase1 p
LEFT JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
LEFT JOIN CMS.UserWalletHistories h ON h.Id = p.WalletHistoryId AND h.IsDeleted = 0
LEFT JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
LEFT JOIN CMS.Transactions t ON t.Id = p.TransactionId AND t.IsDeleted = 0
WHERE u.Id IS NULL
OR h.Id IS NULL
OR t.Id IS NULL
OR h.RefrenceId <> p.TransactionId
OR w.UserId <> p.UserId
OR t.RefId NOT LIKE N'manualy%'
OR t.PaymentStatus <> 0
);
IF @BadValidation > 0
BEGIN
DECLARE @BadMsg NVARCHAR(200);
SET @BadMsg = CONCAT(
N'Validation failed for ', @BadValidation,
N' row(s). Fix before creating UPP.'
);
ROLLBACK;
RAISERROR(@BadMsg, 16, 1);
RETURN;
END
PRINT 'Validation OK for all 17 rows.';
------------------------------------------------------------
-- Preview: who already has UPP / UPP on this Tx
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile,
p.TransactionId,
CASE WHEN upp_any.Id IS NOT NULL THEN N'YES' ELSE N'NO' END AS HasAnyUpp,
upp_any.Id AS ExistingUppId,
upp_any.PurchaseMethod AS ExistingUppMethod,
upp_any.TransactionId AS ExistingUppTxId,
CASE
WHEN upp_tx.Id IS NOT NULL THEN N'SKIP_UPP_EXISTS_FOR_TX'
WHEN upp_any.Id IS NOT NULL THEN N'SKIP_USER_HAS_OTHER_UPP'
ELSE N'CREATE_UPP'
END AS PlanAction
FROM @Phase1 p
INNER JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
OUTER APPLY (
SELECT TOP (1) upp.Id, upp.PurchaseMethod, upp.TransactionId
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId AND upp.IsDeleted = 0
ORDER BY upp.Id DESC
) upp_any
OUTER APPLY (
SELECT TOP (1) upp.Id
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId
AND upp.IsDeleted = 0
AND upp.TransactionId = p.TransactionId
ORDER BY upp.Id DESC
) upp_tx
ORDER BY p.UserId;
------------------------------------------------------------
-- Build UPP insert plan
------------------------------------------------------------
SELECT
p.UserId,
p.TransactionId,
COALESCE(
NULLIF(cm.LastPackageId, 0),
NULLIF(cm.FirstPackageId, 0),
@BasePackageId
) AS PackageId,
COALESCE(t.Amount, @BasePackagePrice) AS Amount,
COALESCE(t.PaymentDate, cm.ActivatedAt, @Now) AS PurchasedAt
INTO #UppPlan
FROM @Phase1 p
INNER JOIN CMS.Transactions t ON t.Id = p.TransactionId AND t.IsDeleted = 0
LEFT JOIN CMS.ClubMemberships cm ON cm.UserId = p.UserId AND cm.IsDeleted = 0
WHERE NOT EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId
AND upp.IsDeleted = 0
AND upp.TransactionId = p.TransactionId
);
DECLARE @ToCreate INT = (SELECT COUNT(*) FROM #UppPlan);
DECLARE @Skipped INT = (SELECT COUNT(*) FROM @Phase1) - @ToCreate;
PRINT CONCAT('UPP to create: ', @ToCreate, ' | Already linked (skip): ', @Skipped);
SELECT
pl.UserId,
u.Mobile,
u.FirstName,
u.LastName,
pl.TransactionId,
pl.PackageId,
pl.Amount,
pl.PurchasedAt,
N'INSERT_UPP_MANUAL_3' AS Action
FROM #UppPlan pl
INNER JOIN CMS.Users u ON u.Id = pl.UserId AND u.IsDeleted = 0
ORDER BY pl.UserId;
------------------------------------------------------------
-- Insert UPP
------------------------------------------------------------
INSERT INTO CMS.UserPackagePurchases
(
UserId, PackageId, PurchaseMethod, PurchasedAt, Amount,
OrderId, TransactionId, Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
SELECT
pl.UserId,
pl.PackageId,
3, -- Manual
pl.PurchasedAt,
pl.Amount,
NULL,
pl.TransactionId,
@Now,
NULL,
@Now,
NULL,
0
FROM #UppPlan pl
WHERE pl.PackageId IS NOT NULL
AND pl.Amount > 0;
PRINT CONCAT('UPP inserted: ', @@ROWCOUNT);
DROP TABLE #UppPlan;
------------------------------------------------------------
-- Verify
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile,
p.TransactionId,
CASE WHEN upp.Id IS NOT NULL THEN N'OK' ELSE N'MISSING_UPP' END AS UppStatus,
upp.Id AS UppId,
upp.PurchaseMethod,
upp.TransactionId AS UppTxId
FROM @Phase1 p
INNER JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
LEFT JOIN CMS.UserPackagePurchases upp
ON upp.UserId = p.UserId
AND upp.IsDeleted = 0
AND upp.TransactionId = p.TransactionId
ORDER BY p.UserId;
SELECT N'Missing UPP after run' AS CheckName, COUNT(*) AS Cnt
FROM @Phase1 p
WHERE NOT EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId
AND upp.IsDeleted = 0
AND upp.TransactionId = p.TransactionId
);
ROLLBACK;
-- COMMIT;
PRINT 'Done (rolled back by default). Uncomment COMMIT when preview looks correct.';
@@ -0,0 +1,368 @@
/*
PHASE 2 — Fix broken WH.RefrenceId + create UPP for 5 remaining manualy txs.
Tx exists; WH either RefrenceId=0 (CreatedBy = Tx Id hint) or missing (Tx 68).
Hardcoded mapping (UserWalletHistories2.csv + Transactions.csv):
UserId WH Tx Notes
------ ----- --- -----
71 118 56 WH Ref=0, CreatedBy=56
72 124 57 WH Ref=0, CreatedBy=57
69 119 58 WH Ref=0, CreatedBy=58
74 120 59 WH Ref=0, CreatedBy=59
142 — 68 no WH — insert history-only (no wallet charge)
Steps:
1) Validate WH hint + Tx manualy% + user/wallet
2) UPDATE WH.RefrenceId for rows 118,124,119,120
3) INSERT UserWalletHistory for User 142 / Tx 68 if missing
4) INSERT UserPackagePurchases Manual(3) for all 5 txs
Does NOT change Users.PackagePurchaseMethod or wallet Balance.
Defaults to ROLLBACK. Uncomment COMMIT after preview.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRAN;
DECLARE @Now DATETIME2 = SYSUTCDATETIME();
DECLARE @BasePackageId BIGINT;
DECLARE @BasePackagePrice BIGINT;
SELECT TOP (1)
@BasePackageId = Id,
@BasePackagePrice = Price
FROM CMS.Packages
WHERE IsDeleted = 0 AND IsBasePackage = 1
ORDER BY Id;
IF @BasePackageId IS NULL
THROW 50001, 'Base package not found.', 1;
DECLARE @Phase2 TABLE (
UserId BIGINT NOT NULL,
WalletHistoryId BIGINT NULL,
TransactionId BIGINT NOT NULL,
PRIMARY KEY (TransactionId)
);
INSERT INTO @Phase2 (UserId, WalletHistoryId, TransactionId) VALUES
(71, 118, 56),
(72, 124, 57),
(69, 119, 58),
(74, 120, 59),
(142, NULL, 68);
DECLARE @Phase2Count INT = (SELECT COUNT(*) FROM @Phase2);
PRINT CONCAT('Phase2 expected rows: ', @Phase2Count);
------------------------------------------------------------
-- 1) Validate before changes
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile AS UserMobile,
u.FirstName,
u.LastName,
p.WalletHistoryId AS ExpectedWhId,
p.TransactionId AS ExpectedTxId,
h.Id AS ActualWhId,
h.RefrenceId AS WhRefrenceId,
h.CreatedBy AS WhCreatedBy,
h.ChangeValue AS WhChangeValue,
w.UserId AS WhWalletUserId,
t.Id AS ActualTxId,
t.RefId AS TxRefId,
t.Amount AS TxAmount,
t.CreatedBy AS TxCreatedBy,
t.LastModifiedBy AS TxLastModifiedBy,
CASE
WHEN u.Id IS NULL THEN N'USER_MISSING'
WHEN t.Id IS NULL THEN N'TX_MISSING'
WHEN t.RefId NOT LIKE N'manualy%' THEN N'TX_NOT_MANUALY'
WHEN t.PaymentStatus <> 0 THEN N'TX_NOT_SUCCESS'
WHEN p.WalletHistoryId IS NULL THEN N'NO_WH_EXPECTED_CREATE'
WHEN h.Id IS NULL THEN N'WH_MISSING'
WHEN w.UserId <> p.UserId THEN N'WH_WALLET_USER_MISMATCH'
WHEN TRY_CAST(h.CreatedBy AS BIGINT) <> p.TransactionId THEN N'WH_CREATEDBY_HINT_MISMATCH'
WHEN h.RefrenceId = p.TransactionId THEN N'WH_ALREADY_LINKED'
WHEN h.RefrenceId IS NOT NULL AND h.RefrenceId <> 0 AND h.RefrenceId <> p.TransactionId
THEN N'WH_REF_POINTS_OTHER_TX'
ELSE N'OK_FIX_REF'
END AS WhValidation,
CASE
WHEN u.Mobile IN (
NULLIF(LTRIM(RTRIM(t.CreatedBy)), N''),
NULLIF(LTRIM(RTRIM(t.LastModifiedBy)), N'')
) THEN N'MOBILE_OK'
WHEN NULLIF(LTRIM(RTRIM(t.CreatedBy)), N'') LIKE N'09%' THEN N'TX_MOBILE_USER_MISMATCH'
ELSE N'TX_NO_MOBILE_ON_ROW'
END AS MobileCheck
FROM @Phase2 p
LEFT JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
LEFT JOIN CMS.Transactions t ON t.Id = p.TransactionId AND t.IsDeleted = 0
LEFT JOIN CMS.UserWalletHistories h ON h.Id = p.WalletHistoryId AND h.IsDeleted = 0
LEFT JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
ORDER BY p.TransactionId;
DECLARE @BadWh INT = (
SELECT COUNT(*)
FROM @Phase2 p
LEFT JOIN CMS.UserWalletHistories h ON h.Id = p.WalletHistoryId AND h.IsDeleted = 0
LEFT JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
LEFT JOIN CMS.Transactions t ON t.Id = p.TransactionId AND t.IsDeleted = 0
LEFT JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
WHERE t.Id IS NULL
OR u.Id IS NULL
OR t.RefId NOT LIKE N'manualy%'
OR t.PaymentStatus <> 0
OR (
p.WalletHistoryId IS NOT NULL
AND (
h.Id IS NULL
OR w.UserId <> p.UserId
OR TRY_CAST(h.CreatedBy AS BIGINT) <> p.TransactionId
OR (h.RefrenceId IS NOT NULL AND h.RefrenceId <> 0 AND h.RefrenceId <> p.TransactionId)
)
)
);
IF @BadWh > 0
BEGIN
DECLARE @BadMsg NVARCHAR(200);
SET @BadMsg = CONCAT(N'Validation failed for ', @BadWh, N' row(s). Aborting.');
ROLLBACK;
RAISERROR(@BadMsg, 16, 1);
RETURN;
END
PRINT 'Validation OK.';
------------------------------------------------------------
-- 2) Fix WH.RefrenceId (118,124,119,120)
------------------------------------------------------------
SELECT
p.UserId,
p.WalletHistoryId,
p.TransactionId,
h.RefrenceId AS OldRefrenceId,
N'UPDATE RefrenceId' AS Action
FROM @Phase2 p
INNER JOIN CMS.UserWalletHistories h ON h.Id = p.WalletHistoryId AND h.IsDeleted = 0
WHERE p.WalletHistoryId IS NOT NULL
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0 OR h.RefrenceId <> p.TransactionId);
UPDATE h
SET
h.RefrenceId = p.TransactionId,
h.LastModified = @Now
FROM @Phase2 p
INNER JOIN CMS.UserWalletHistories h ON h.Id = p.WalletHistoryId AND h.IsDeleted = 0
WHERE p.WalletHistoryId IS NOT NULL
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0);
PRINT CONCAT('WalletHistory RefrenceId updated: ', @@ROWCOUNT);
------------------------------------------------------------
-- 3) User 142 / Tx 68 — history-only WH if missing
------------------------------------------------------------
SELECT
p.UserId,
p.TransactionId,
w.Id AS WalletId,
h.Id AS ExistingWhId,
CASE WHEN h.Id IS NOT NULL THEN N'SKIP_WH_EXISTS' ELSE N'CREATE_WH_HISTORY' END AS WhPlan
FROM @Phase2 p
LEFT JOIN CMS.UserWallets w ON w.UserId = p.UserId AND w.IsDeleted = 0
LEFT JOIN CMS.UserWalletHistories h ON h.IsDeleted = 0 AND h.RefrenceId = p.TransactionId
AND h.WalletId = w.Id
WHERE p.WalletHistoryId IS NULL;
DECLARE @UserId BIGINT = 142;
DECLARE @TxId BIGINT = 68;
DECLARE @WalletId BIGINT;
DECLARE @PackageId BIGINT;
DECLARE @Amount BIGINT;
DECLARE @HistoryAt DATETIME2;
DECLARE @DiscountAmount BIGINT;
DECLARE @NetworkBalance BIGINT;
IF EXISTS (SELECT 1 FROM @Phase2 WHERE TransactionId = 68 AND WalletHistoryId IS NULL)
BEGIN
SELECT
@WalletId = w.Id,
@PackageId = COALESCE(
NULLIF(cm.LastPackageId, 0),
NULLIF(cm.FirstPackageId, 0),
@BasePackageId
),
@Amount = t.Amount,
@HistoryAt = COALESCE(t.PaymentDate, @Now),
@NetworkBalance = COALESCE(w.NetworkBalance, 0),
@DiscountAmount = CAST(ROUND(t.Amount * COALESCE(pkg.DiscountMultiplier, 2.0), 0) AS BIGINT)
FROM CMS.Transactions t
INNER JOIN CMS.Users u ON u.Id = 142 AND u.IsDeleted = 0
LEFT JOIN CMS.UserWallets w ON w.UserId = u.Id AND w.IsDeleted = 0
LEFT JOIN CMS.ClubMemberships cm ON cm.UserId = u.Id AND cm.IsDeleted = 0
LEFT JOIN CMS.Packages pkg ON pkg.Id = COALESCE(
NULLIF(cm.LastPackageId, 0),
NULLIF(cm.FirstPackageId, 0),
@BasePackageId
) AND pkg.IsDeleted = 0
WHERE t.Id = 68 AND t.IsDeleted = 0;
IF @WalletId IS NULL
BEGIN
INSERT INTO CMS.UserWallets
(UserId, Balance, NetworkBalance, DiscountBalance, WalletMode, Created, LastModified, IsDeleted)
VALUES (142, 0, 0, 0, 0, @HistoryAt, @HistoryAt, 0);
SET @WalletId = SCOPE_IDENTITY();
SET @NetworkBalance = 0;
PRINT CONCAT('Wallet created for User 142: ', @WalletId);
END
IF NOT EXISTS (
SELECT 1
FROM CMS.UserWalletHistories h
WHERE h.WalletId = @WalletId
AND h.IsDeleted = 0
AND h.RefrenceId = 68
)
BEGIN
INSERT INTO CMS.UserWalletHistories
(
WalletId, CurrentBalance, ChangeValue, CurrentNetworkBalance, ChangeNerworkValue,
CurrentDiscountBalance, ChangeDiscountValue, IsIncrease, RefrenceId, PackageId,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@WalletId, 0, @Amount, @NetworkBalance, 0,
0, @DiscountAmount, 1, 68, @PackageId,
@HistoryAt, NULL, @HistoryAt, NULL, 0
);
PRINT CONCAT('WalletHistory created for Tx 68 (no charge): WH Id=', SCOPE_IDENTITY());
END
ELSE
PRINT 'WalletHistory for Tx 68 already exists — skipped.';
END
------------------------------------------------------------
-- 4) UPP preview + insert
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile,
p.TransactionId,
CASE WHEN upp.Id IS NOT NULL THEN N'SKIP_UPP_EXISTS' ELSE N'CREATE_UPP' END AS UppPlan,
upp.Id AS ExistingUppId
FROM @Phase2 p
INNER JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
LEFT JOIN CMS.UserPackagePurchases upp
ON upp.UserId = p.UserId
AND upp.IsDeleted = 0
AND upp.TransactionId = p.TransactionId
ORDER BY p.TransactionId;
SELECT
p.UserId,
p.TransactionId,
COALESCE(
NULLIF(cm.LastPackageId, 0),
NULLIF(cm.FirstPackageId, 0),
@BasePackageId
) AS PackageId,
COALESCE(t.Amount, @BasePackagePrice) AS Amount,
COALESCE(t.PaymentDate, cm.ActivatedAt, @Now) AS PurchasedAt
INTO #UppPlan
FROM @Phase2 p
INNER JOIN CMS.Transactions t ON t.Id = p.TransactionId AND t.IsDeleted = 0
LEFT JOIN CMS.ClubMemberships cm ON cm.UserId = p.UserId AND cm.IsDeleted = 0
WHERE NOT EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId
AND upp.IsDeleted = 0
AND upp.TransactionId = p.TransactionId
);
DECLARE @UppToCreate INT = (SELECT COUNT(*) FROM #UppPlan);
PRINT CONCAT('UPP to create: ', @UppToCreate);
INSERT INTO CMS.UserPackagePurchases
(
UserId, PackageId, PurchaseMethod, PurchasedAt, Amount,
OrderId, TransactionId, Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
SELECT
pl.UserId,
pl.PackageId,
3,
pl.PurchasedAt,
pl.Amount,
NULL,
pl.TransactionId,
@Now,
NULL,
@Now,
NULL,
0
FROM #UppPlan pl
WHERE pl.PackageId IS NOT NULL AND pl.Amount > 0;
PRINT CONCAT('UPP inserted: ', @@ROWCOUNT);
DROP TABLE #UppPlan;
------------------------------------------------------------
-- 5) Verify
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile,
p.TransactionId,
h.Id AS WalletHistoryId,
h.RefrenceId AS WhRefrenceId,
upp.Id AS UppId,
upp.PurchaseMethod,
CASE
WHEN h.Id IS NULL AND p.WalletHistoryId IS NOT NULL THEN N'MISSING_WH'
WHEN h.RefrenceId <> p.TransactionId AND p.WalletHistoryId IS NOT NULL THEN N'WH_REF_WRONG'
WHEN NOT EXISTS (
SELECT 1 FROM CMS.UserWalletHistories hx
INNER JOIN CMS.UserWallets wx ON wx.Id = hx.WalletId AND wx.UserId = p.UserId
WHERE hx.IsDeleted = 0 AND hx.RefrenceId = p.TransactionId
) THEN N'NO_WH_FOR_TX'
WHEN upp.Id IS NULL THEN N'MISSING_UPP'
ELSE N'OK'
END AS FinalStatus
FROM @Phase2 p
INNER JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
LEFT JOIN CMS.UserWalletHistories h ON h.Id = p.WalletHistoryId AND h.IsDeleted = 0
LEFT JOIN CMS.UserPackagePurchases upp
ON upp.UserId = p.UserId
AND upp.IsDeleted = 0
AND upp.TransactionId = p.TransactionId
ORDER BY p.TransactionId;
SELECT N'Incomplete after run' AS CheckName, COUNT(*) AS Cnt
FROM @Phase2 p
WHERE NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId AND upp.IsDeleted = 0 AND upp.TransactionId = p.TransactionId
)
OR NOT EXISTS (
SELECT 1
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.UserId = p.UserId
WHERE h.IsDeleted = 0 AND h.RefrenceId = p.TransactionId
);
ROLLBACK;
-- COMMIT;
PRINT 'Done (rolled back by default). Uncomment COMMIT when preview looks correct.';
@@ -0,0 +1,355 @@
/*
PHASE 3 — Remaining 16 users from WrongDaya→Manual list (no manualy Tx from Phase 1/2).
Users:
2,7,8,9,10,11,12,13,40,41,105,106,130,162,169,176
Already done in Phase 1+2 (22 users with manualy Tx): excluded here.
Per user (skip if UPP already exists for that UserId):
1) Reuse deposit Tx if WH links to Success Tx with Amount=56000000
2) Else fix WH.RefrenceId when CreatedBy hints an existing manualy Tx
3) Else CREATE Transaction (RefId FIX3-{UserId}) — no wallet charge
4) Link stub WH (Ref=0) to Tx OR insert history-only WH
5) INSERT UserPackagePurchases Manual(3)
User 162: skipped for UPP if any UPP already exists (has UPP from ManualPayment flow).
Does NOT change Users.PackagePurchaseMethod (Phase 4 later).
Defaults to ROLLBACK. Uncomment COMMIT after preview.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRAN;
DECLARE @Now DATETIME2 = SYSUTCDATETIME();
DECLARE @ChargeAmount BIGINT = 56000000;
DECLARE @BasePackageId BIGINT;
DECLARE @BasePackagePrice BIGINT;
SELECT TOP (1)
@BasePackageId = Id,
@BasePackagePrice = Price
FROM CMS.Packages
WHERE IsDeleted = 0 AND IsBasePackage = 1
ORDER BY Id;
IF @BasePackageId IS NULL
THROW 50001, 'Base package not found.', 1;
DECLARE @Phase3 TABLE (UserId BIGINT PRIMARY KEY);
INSERT INTO @Phase3 (UserId) VALUES
(2),(7),(8),(9),(10),(11),(12),(13),(40),(41),
(105),(106),(130),(162),(169),(176);
DECLARE @Phase3Count INT = (SELECT COUNT(*) FROM @Phase3);
PRINT CONCAT('Phase3 users: ', @Phase3Count);
------------------------------------------------------------
-- Preview: current ledger state
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile,
u.FirstName,
u.LastName,
u.PackagePurchaseMethod,
(SELECT COUNT(*) FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId AND upp.IsDeleted = 0) AS UppCount,
(SELECT TOP (1) upp.Id FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId AND upp.IsDeleted = 0 ORDER BY upp.Id DESC) AS LatestUppId,
(SELECT COUNT(*) FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
WHERE w.UserId = p.UserId AND h.IsDeleted = 0) AS WhCount,
(SELECT COUNT(*) FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
WHERE w.UserId = p.UserId AND h.IsDeleted = 0
AND h.ChangeValue >= @ChargeAmount AND h.RefrenceId > 0) AS WhFullDepositCount,
CASE
WHEN EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId AND upp.IsDeleted = 0
) THEN N'SKIP_HAS_UPP'
ELSE N'NEEDS_UPP'
END AS UppPlan
FROM @Phase3 p
INNER JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
ORDER BY p.UserId;
------------------------------------------------------------
-- Resolve Tx per user needing UPP
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile,
COALESCE(
NULLIF(cm.LastPackageId, 0),
NULLIF(cm.FirstPackageId, 0),
@BasePackageId
) AS PackageId,
COALESCE(cm.ActivatedAt, cm.Created, @Now) AS PurchasedAt,
dep.TxId AS ExistingDepositTxId,
dep.WhId AS ExistingDepositWhId,
stub.StubWhId,
stub.StubCreatedBy,
CAST(NULL AS BIGINT) AS ResolvedTxId,
CAST(N'' AS NVARCHAR(40)) AS TxSource,
CAST(N'' AS NVARCHAR(40)) AS PlanAction
INTO #Plan
FROM @Phase3 p
INNER JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
LEFT JOIN CMS.ClubMemberships cm ON cm.UserId = p.UserId AND cm.IsDeleted = 0
OUTER APPLY (
SELECT TOP (1)
t.Id AS TxId,
h.Id AS WhId
FROM CMS.UserWallets w
INNER JOIN CMS.UserWalletHistories h ON h.WalletId = w.Id AND h.IsDeleted = 0
INNER JOIN CMS.Transactions t ON t.Id = h.RefrenceId
AND t.IsDeleted = 0 AND t.PaymentStatus = 0 AND t.Amount = @ChargeAmount
WHERE w.UserId = p.UserId AND w.IsDeleted = 0
AND h.ChangeValue >= @ChargeAmount
AND h.IsIncrease = 1
ORDER BY h.Id ASC
) dep
OUTER APPLY (
SELECT TOP (1)
h.Id AS StubWhId,
h.CreatedBy AS StubCreatedBy
FROM CMS.UserWallets w
INNER JOIN CMS.UserWalletHistories h ON h.WalletId = w.Id AND h.IsDeleted = 0
WHERE w.UserId = p.UserId AND w.IsDeleted = 0
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0)
ORDER BY h.Id ASC
) stub
WHERE NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId AND upp.IsDeleted = 0
);
-- Pass 1: existing full deposit WH→Tx
UPDATE pl
SET
ResolvedTxId = pl.ExistingDepositTxId,
TxSource = N'WH_DEPOSIT',
PlanAction = N'CREATE_UPP_REUSE_TX'
FROM #Plan pl
WHERE pl.ExistingDepositTxId IS NOT NULL;
-- Pass 2: stub CreatedBy → manualy Tx
UPDATE pl
SET
ResolvedTxId = t.Id,
TxSource = N'WH_STUB_HINT',
PlanAction = N'FIX_STUB_LINK_UPP'
FROM #Plan pl
INNER JOIN CMS.Transactions t ON t.Id = TRY_CAST(pl.StubCreatedBy AS BIGINT)
AND t.IsDeleted = 0 AND t.PaymentStatus = 0 AND t.RefId LIKE N'manualy%'
WHERE pl.ResolvedTxId IS NULL
AND TRY_CAST(pl.StubCreatedBy AS BIGINT) > 0;
-- Pass 3: need new Tx
UPDATE pl
SET
TxSource = N'CREATE_TX',
PlanAction = N'CREATE_TX_WH_UPP'
FROM #Plan pl
WHERE pl.ResolvedTxId IS NULL;
SELECT
UserId, Mobile, PackageId, PurchasedAt,
ExistingDepositTxId, ExistingDepositWhId,
StubWhId, StubCreatedBy,
ResolvedTxId, TxSource, PlanAction
FROM #Plan
ORDER BY UserId;
DECLARE @NeedPlan INT = (SELECT COUNT(*) FROM #Plan);
PRINT CONCAT('Users needing UPP in Phase3: ', @NeedPlan);
------------------------------------------------------------
-- Execute: cursor per user
------------------------------------------------------------
DECLARE @UserId BIGINT;
DECLARE @Mobile NVARCHAR(50);
DECLARE @PackageId BIGINT;
DECLARE @PurchasedAt DATETIME2;
DECLARE @ExistingDepositTxId BIGINT;
DECLARE @StubWhId BIGINT;
DECLARE @ResolvedTxId BIGINT;
DECLARE @TxSource NVARCHAR(40);
DECLARE @TxId BIGINT;
DECLARE @WalletId BIGINT;
DECLARE @DiscountAmount BIGINT;
DECLARE @NetworkBalance BIGINT;
DECLARE @HistoryAt DATETIME2;
DECLARE @CreatedTx INT = 0;
DECLARE @CreatedUpp INT = 0;
DECLARE @CreatedWh INT = 0;
DECLARE @FixedWh INT = 0;
DECLARE cur CURSOR LOCAL FAST_FORWARD FOR
SELECT UserId, Mobile, PackageId, PurchasedAt,
ExistingDepositTxId, StubWhId, ResolvedTxId, TxSource
FROM #Plan;
OPEN cur;
FETCH NEXT FROM cur INTO
@UserId, @Mobile, @PackageId, @PurchasedAt,
@ExistingDepositTxId, @StubWhId, @ResolvedTxId, @TxSource;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @TxId = @ResolvedTxId;
IF @TxId IS NULL
BEGIN
INSERT INTO CMS.Transactions
(
Amount, Description, PaymentStatus, PaymentDate, RefId, Type,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@ChargeAmount,
N'عضویت دستی باشگاه مشتریان - backfill Phase3 WrongDaya',
0,
@PurchasedAt,
CONCAT(N'FIX3-', @UserId),
2,
@PurchasedAt,
@Mobile,
@PurchasedAt,
@Mobile,
0
);
SET @TxId = SCOPE_IDENTITY();
SET @CreatedTx = @CreatedTx + 1;
END
IF @StubWhId IS NOT NULL
AND EXISTS (
SELECT 1 FROM CMS.UserWalletHistories h
WHERE h.Id = @StubWhId AND h.IsDeleted = 0
AND (h.RefrenceId IS NULL OR h.RefrenceId = 0)
)
BEGIN
UPDATE CMS.UserWalletHistories
SET RefrenceId = @TxId, LastModified = @Now
WHERE Id = @StubWhId;
SET @FixedWh = @FixedWh + @@ROWCOUNT;
END
SELECT @WalletId = w.Id, @NetworkBalance = COALESCE(w.NetworkBalance, 0)
FROM CMS.UserWallets w
WHERE w.UserId = @UserId AND w.IsDeleted = 0;
IF @WalletId IS NULL
BEGIN
INSERT INTO CMS.UserWallets
(UserId, Balance, NetworkBalance, DiscountBalance, WalletMode, Created, LastModified, IsDeleted)
VALUES (@UserId, 0, 0, 0, 0, @PurchasedAt, @PurchasedAt, 0);
SET @WalletId = SCOPE_IDENTITY();
SET @NetworkBalance = 0;
END
IF NOT EXISTS (
SELECT 1 FROM CMS.UserWalletHistories h
WHERE h.WalletId = @WalletId AND h.IsDeleted = 0 AND h.RefrenceId = @TxId
)
BEGIN
SELECT
@HistoryAt = COALESCE(t.PaymentDate, @PurchasedAt),
@DiscountAmount = CAST(ROUND(@ChargeAmount * COALESCE(pkg.DiscountMultiplier, 2.0), 0) AS BIGINT)
FROM CMS.Transactions t
LEFT JOIN CMS.Packages pkg ON pkg.Id = @PackageId AND pkg.IsDeleted = 0
WHERE t.Id = @TxId;
INSERT INTO CMS.UserWalletHistories
(
WalletId, CurrentBalance, ChangeValue, CurrentNetworkBalance, ChangeNerworkValue,
CurrentDiscountBalance, ChangeDiscountValue, IsIncrease, RefrenceId, PackageId,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@WalletId, 0, @ChargeAmount, @NetworkBalance, 0,
0, @DiscountAmount, 1, @TxId, @PackageId,
@HistoryAt, NULL, @HistoryAt, NULL, 0
);
SET @CreatedWh = @CreatedWh + 1;
END
IF NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = @UserId AND upp.IsDeleted = 0 AND upp.TransactionId = @TxId
)
BEGIN
INSERT INTO CMS.UserPackagePurchases
(
UserId, PackageId, PurchaseMethod, PurchasedAt, Amount,
OrderId, TransactionId, Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@UserId, @PackageId, 3,
@PurchasedAt, @ChargeAmount,
NULL, @TxId, @Now, NULL, @Now, NULL, 0
);
SET @CreatedUpp = @CreatedUpp + 1;
END
FETCH NEXT FROM cur INTO
@UserId, @Mobile, @PackageId, @PurchasedAt,
@ExistingDepositTxId, @StubWhId, @ResolvedTxId, @TxSource;
END
CLOSE cur;
DEALLOCATE cur;
DROP TABLE #Plan;
PRINT CONCAT('Tx created: ', @CreatedTx,
' | WH Ref fixed: ', @FixedWh,
' | WH created: ', @CreatedWh,
' | UPP created: ', @CreatedUpp);
------------------------------------------------------------
-- Verify all 16 have UPP
------------------------------------------------------------
SELECT
p.UserId,
u.Mobile,
CASE WHEN upp.UppId IS NOT NULL THEN N'OK' ELSE N'MISSING_UPP' END AS UppStatus,
upp.UppId,
upp.PurchaseMethod,
upp.TransactionId AS UppTxId,
t.RefId AS TxRefId
FROM @Phase3 p
INNER JOIN CMS.Users u ON u.Id = p.UserId AND u.IsDeleted = 0
OUTER APPLY (
SELECT TOP (1)
upp2.Id AS UppId,
upp2.PurchaseMethod,
upp2.TransactionId
FROM CMS.UserPackagePurchases upp2
WHERE upp2.UserId = p.UserId AND upp2.IsDeleted = 0
ORDER BY upp2.Id DESC
) upp
LEFT JOIN CMS.Transactions t ON t.Id = upp.TransactionId
ORDER BY p.UserId;
SELECT N'Phase3 users still without UPP' AS CheckName, COUNT(*) AS Cnt
FROM @Phase3 p
WHERE NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = p.UserId AND upp.IsDeleted = 0
);
ROLLBACK;
-- COMMIT;
PRINT 'Done (rolled back by default). Uncomment COMMIT when preview looks correct.';
@@ -0,0 +1,740 @@
/*
Fix: Manual payment wrongly stored as DirectPurchase (2) → Manual (3)
+ create missing Tx/UPP/UserWalletHistory (same ledger as CreateManualPayment service)
Background:
Before commit 427d2cc, CreateManualPayment set Users.PackagePurchaseMethod = DirectPurchase
and older rows may lack UserPackagePurchases / wallet history entirely.
Scope — ALL users present in CMS.ManualPayments (IsDeleted = 0):
1) Users / UPP / Club / Cycle: PurchaseMethod 2 → 3
2) Users with NO UPP (~18): create Success Tx + UPP Manual(3)
3) After that, for EVERY user missing UserWalletHistory on their Tx (~6 existing + ~18 new):
- INSERT history only (Created=PaymentDate, ChangeValue=Amount)
- DO NOT re-charge wallet
- Prefer existing MP/UPP TransactionId (the ~6 already have Tx)
PackagePurchaseMethod: None=0, DayaLoan=1, DirectPurchase=2, Manual=3
ManualPaymentStatus: Pending=0, Approved=1, Rejected=2
Defaults to ROLLBACK. Uncomment COMMIT after verifying PRINT / preview.
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRAN;
DECLARE @Now DATETIME2 = SYSUTCDATETIME();
DECLARE @BasePackageId BIGINT;
DECLARE @BasePackagePrice BIGINT;
SELECT TOP (1)
@BasePackageId = Id,
@BasePackagePrice = Price
FROM CMS.Packages
WHERE IsDeleted = 0 AND IsBasePackage = 1
ORDER BY Id;
IF @BasePackageId IS NULL
THROW 50001, 'Base package not found.', 1;
------------------------------------------------------------
-- Target: every user that appears in ManualPayments
------------------------------------------------------------
DECLARE @FixUsers TABLE (UserId BIGINT PRIMARY KEY);
INSERT INTO @FixUsers (UserId)
SELECT DISTINCT mp.UserId
FROM CMS.ManualPayments mp
WHERE mp.IsDeleted = 0;
DECLARE @TargetUserCount INT = (SELECT COUNT(*) FROM @FixUsers);
PRINT CONCAT('Target users (all ManualPayments): ', @TargetUserCount);
PRINT CONCAT('BasePackageId=', @BasePackageId, ' Price=', @BasePackagePrice);
------------------------------------------------------------
-- Preview before
-- NOTE: UPP row-count ≠ distinct users (one user can have 2+ UPP rows)
------------------------------------------------------------
SELECT 'Users DirectPurchase (in scope)' AS CheckName, COUNT(*) AS Cnt
FROM CMS.Users u
INNER JOIN @FixUsers f ON f.UserId = u.Id
WHERE u.IsDeleted = 0 AND u.PackagePurchaseMethod = 2
UNION ALL
SELECT 'UPP DirectPurchase ROWS (in scope)', COUNT(*)
FROM CMS.UserPackagePurchases upp
INNER JOIN @FixUsers f ON f.UserId = upp.UserId
WHERE upp.IsDeleted = 0 AND upp.PurchaseMethod = 2
UNION ALL
SELECT 'Users with UPP DirectPurchase (distinct)', COUNT(DISTINCT upp.UserId)
FROM CMS.UserPackagePurchases upp
INNER JOIN @FixUsers f ON f.UserId = upp.UserId
WHERE upp.IsDeleted = 0 AND upp.PurchaseMethod = 2
UNION ALL
SELECT 'ClubMembership DirectPurchase (in scope)', COUNT(*)
FROM CMS.ClubMemberships m
INNER JOIN @FixUsers f ON f.UserId = m.UserId
WHERE m.IsDeleted = 0 AND m.PurchaseMethod = 2
UNION ALL
SELECT 'Cycle DirectPurchase (in scope)', COUNT(*)
FROM CMS.ClubMembershipCycles c
INNER JOIN @FixUsers f ON f.UserId = c.UserId
WHERE c.IsDeleted = 0 AND c.PurchaseMethod = 2
UNION ALL
SELECT 'Users with any UPP (distinct)', COUNT(*)
FROM @FixUsers f
WHERE EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = f.UserId AND upp.IsDeleted = 0
)
UNION ALL
SELECT 'Missing any UPP (will create Tx+UPP, then WH)', COUNT(*)
FROM @FixUsers f
WHERE NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = f.UserId AND upp.IsDeleted = 0
)
UNION ALL
SELECT 'Missing WH — have Tx already (create history only)', COUNT(*)
FROM @FixUsers f
OUTER APPLY (
SELECT TOP (1)
mp.Id AS ManualPaymentId,
mp.TransactionId AS MpTxId,
mp.ReferenceNumber
FROM CMS.ManualPayments mp
WHERE mp.UserId = f.UserId AND mp.IsDeleted = 0
ORDER BY
CASE WHEN mp.Status = 1 THEN 0 ELSE 1 END,
CASE WHEN mp.TransactionId IS NOT NULL THEN 0 ELSE 1 END,
COALESCE(mp.ApprovedAt, mp.Created) DESC,
mp.Id DESC
) mp
OUTER APPLY (
SELECT TOP (1) upp.TransactionId AS UppTxId
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = f.UserId AND upp.IsDeleted = 0 AND upp.TransactionId IS NOT NULL
ORDER BY upp.Id DESC
) upp
CROSS APPLY (
SELECT COALESCE(
CASE
WHEN mp.MpTxId IS NOT NULL
AND EXISTS (
SELECT 1 FROM CMS.Transactions t
WHERE t.Id = mp.MpTxId AND t.IsDeleted = 0 AND t.PaymentStatus = 0
)
THEN mp.MpTxId
END,
upp.UppTxId,
(
SELECT TOP (1) t.Id
FROM CMS.Transactions t
WHERE t.IsDeleted = 0 AND t.PaymentStatus = 0
AND mp.ReferenceNumber IS NOT NULL
AND t.RefId = mp.ReferenceNumber
ORDER BY t.Id DESC
)
) AS TxId
) tx
WHERE tx.TxId IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
WHERE w.UserId = f.UserId
AND h.IsDeleted = 0
AND h.RefrenceId = tx.TxId
);
-- Diagnostic: users who already have UPP (expect ~6) — Tx link vs history
SELECT
f.UserId,
mp.ManualPaymentId,
mp.MpTxId,
upp.UppTxId,
tx.TxId AS ResolvedTxId,
CASE
WHEN tx.TxId IS NULL THEN N'NO_TX_RESOLVED'
WHEN h.Id IS NULL THEN N'MISSING_HISTORY'
ELSE N'HAS_HISTORY'
END AS HistoryStatus,
h.Id AS WalletHistoryId
FROM @FixUsers f
OUTER APPLY (
SELECT TOP (1)
mp0.Id AS ManualPaymentId,
mp0.TransactionId AS MpTxId,
mp0.ReferenceNumber
FROM CMS.ManualPayments mp0
WHERE mp0.UserId = f.UserId AND mp0.IsDeleted = 0
ORDER BY
CASE WHEN mp0.Status = 1 THEN 0 ELSE 1 END,
CASE WHEN mp0.TransactionId IS NOT NULL THEN 0 ELSE 1 END,
COALESCE(mp0.ApprovedAt, mp0.Created) DESC,
mp0.Id DESC
) mp
OUTER APPLY (
SELECT TOP (1) upp0.TransactionId AS UppTxId
FROM CMS.UserPackagePurchases upp0
WHERE upp0.UserId = f.UserId AND upp0.IsDeleted = 0 AND upp0.TransactionId IS NOT NULL
ORDER BY upp0.Id DESC
) upp
CROSS APPLY (
SELECT COALESCE(
CASE
WHEN mp.MpTxId IS NOT NULL
AND EXISTS (
SELECT 1 FROM CMS.Transactions t
WHERE t.Id = mp.MpTxId AND t.IsDeleted = 0 AND t.PaymentStatus = 0
)
THEN mp.MpTxId
END,
upp.UppTxId,
(
SELECT TOP (1) t.Id
FROM CMS.Transactions t
WHERE t.IsDeleted = 0 AND t.PaymentStatus = 0
AND mp.ReferenceNumber IS NOT NULL
AND t.RefId = mp.ReferenceNumber
ORDER BY t.Id DESC
)
) AS TxId
) tx
OUTER APPLY (
SELECT TOP (1) h0.Id
FROM CMS.UserWalletHistories h0
INNER JOIN CMS.UserWallets w0 ON w0.Id = h0.WalletId AND w0.IsDeleted = 0
WHERE w0.UserId = f.UserId AND h0.IsDeleted = 0 AND h0.RefrenceId = tx.TxId
) h
WHERE EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases uany
WHERE uany.UserId = f.UserId AND uany.IsDeleted = 0
)
ORDER BY f.UserId;
-- Who has more than one UPP row? (explains ROWS vs users)
SELECT
upp.UserId,
COUNT(*) AS UppRowCnt,
SUM(CASE WHEN upp.PurchaseMethod = 2 THEN 1 ELSE 0 END) AS UppDirectPurchaseCnt,
SUM(CASE WHEN upp.PurchaseMethod = 3 THEN 1 ELSE 0 END) AS UppManualCnt
FROM CMS.UserPackagePurchases upp
INNER JOIN @FixUsers f ON f.UserId = upp.UserId
WHERE upp.IsDeleted = 0
GROUP BY upp.UserId
HAVING COUNT(*) > 1
ORDER BY UppRowCnt DESC, upp.UserId;
------------------------------------------------------------
-- 1) Users 2 → 3
------------------------------------------------------------
UPDATE u
SET
u.PackagePurchaseMethod = 3,
u.LastModified = @Now
FROM CMS.Users u
INNER JOIN @FixUsers f ON f.UserId = u.Id
WHERE u.IsDeleted = 0
AND u.PackagePurchaseMethod = 2;
PRINT CONCAT('Users updated 2→3: ', @@ROWCOUNT);
------------------------------------------------------------
-- 2) Existing UPP 2 → 3
------------------------------------------------------------
UPDATE upp
SET
upp.PurchaseMethod = 3,
upp.LastModified = @Now
FROM CMS.UserPackagePurchases upp
INNER JOIN @FixUsers f ON f.UserId = upp.UserId
WHERE upp.IsDeleted = 0
AND upp.PurchaseMethod = 2;
PRINT CONCAT('UserPackagePurchases updated 2→3: ', @@ROWCOUNT);
------------------------------------------------------------
-- 3) ClubMemberships 2 → 3
------------------------------------------------------------
UPDATE m
SET
m.PurchaseMethod = 3,
m.LastModified = @Now
FROM CMS.ClubMemberships m
INNER JOIN @FixUsers f ON f.UserId = m.UserId
WHERE m.IsDeleted = 0
AND m.PurchaseMethod = 2;
PRINT CONCAT('ClubMemberships updated 2→3: ', @@ROWCOUNT);
------------------------------------------------------------
-- 4) ClubMembershipCycles 2 → 3
------------------------------------------------------------
UPDATE c
SET
c.PurchaseMethod = 3,
c.LastModified = @Now
FROM CMS.ClubMembershipCycles c
INNER JOIN @FixUsers f ON f.UserId = c.UserId
WHERE c.IsDeleted = 0
AND c.PurchaseMethod = 2;
PRINT CONCAT('ClubMembershipCycles updated 2→3: ', @@ROWCOUNT);
------------------------------------------------------------
-- 5) Create missing Tx + UPP (like CreateManualPayment ledger)
------------------------------------------------------------
;WITH MissingUppUsers AS (
-- Any non-deleted UPP counts as present (avoid duplicate for method=2 / broken Tx)
SELECT f.UserId
FROM @FixUsers f
WHERE NOT EXISTS (
SELECT 1
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = f.UserId
AND upp.IsDeleted = 0
)
),
BestManual AS (
SELECT
mp.UserId,
mp.Id AS ManualPaymentId,
mp.Amount,
mp.TransactionId,
mp.ReferenceNumber,
mp.Description,
COALESCE(mp.ApprovedAt, mp.Created) AS PurchasedAt,
ROW_NUMBER() OVER (
PARTITION BY mp.UserId
ORDER BY
CASE WHEN mp.Status = 1 THEN 0 ELSE 1 END, -- Approved first
CASE WHEN mp.TransactionId IS NOT NULL THEN 0 ELSE 1 END,
COALESCE(mp.ApprovedAt, mp.Created) DESC,
mp.Id DESC
) AS rn
FROM CMS.ManualPayments mp
INNER JOIN MissingUppUsers m ON m.UserId = mp.UserId
WHERE mp.IsDeleted = 0
),
Candidates AS (
SELECT
bm.UserId,
bm.ManualPaymentId,
bm.TransactionId AS ExistingTxId,
bm.ReferenceNumber,
bm.Description,
bm.PurchasedAt,
COALESCE(NULLIF(cm.LastPackageId, 0), NULLIF(cm.FirstPackageId, 0), @BasePackageId) AS PackageId,
CASE
WHEN bm.Amount IS NOT NULL AND bm.Amount > 0 THEN bm.Amount
ELSE COALESCE(p.Price, @BasePackagePrice)
END AS Amount
FROM BestManual bm
LEFT JOIN CMS.ClubMemberships cm
ON cm.UserId = bm.UserId AND cm.IsDeleted = 0
LEFT JOIN CMS.Packages p
ON p.Id = COALESCE(NULLIF(cm.LastPackageId, 0), NULLIF(cm.FirstPackageId, 0), @BasePackageId)
AND p.IsDeleted = 0
WHERE bm.rn = 1
)
SELECT *
INTO #UppToCreate
FROM Candidates
WHERE PackageId IS NOT NULL AND Amount > 0;
DECLARE @MissingUppCount INT = (SELECT COUNT(*) FROM #UppToCreate);
PRINT CONCAT('Missing UPP candidates to create: ', @MissingUppCount);
SELECT
UserId,
ManualPaymentId,
ExistingTxId,
PackageId,
Amount,
PurchasedAt,
CASE WHEN ExistingTxId IS NOT NULL THEN N'REUSE_MP_TX' ELSE N'CREATE_NEW_TX' END AS TxPlan
FROM #UppToCreate
ORDER BY UserId;
DECLARE @UserId BIGINT;
DECLARE @ManualPaymentId BIGINT;
DECLARE @ExistingTxId BIGINT;
DECLARE @PackageId BIGINT;
DECLARE @Amount BIGINT;
DECLARE @PurchasedAt DATETIME2;
DECLARE @ReferenceNumber NVARCHAR(200);
DECLARE @Description NVARCHAR(500);
DECLARE @TxId BIGINT;
DECLARE @CreatedUpp INT = 0;
DECLARE @CreatedTx INT = 0;
DECLARE @ReusedTx INT = 0;
DECLARE cur CURSOR LOCAL FAST_FORWARD FOR
SELECT UserId, ManualPaymentId, ExistingTxId, PackageId, Amount, PurchasedAt, ReferenceNumber, Description
FROM #UppToCreate;
OPEN cur;
FETCH NEXT FROM cur INTO
@UserId, @ManualPaymentId, @ExistingTxId, @PackageId, @Amount, @PurchasedAt, @ReferenceNumber, @Description;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @TxId = NULL;
-- Reuse Success Tx from ManualPayment when possible
IF @ExistingTxId IS NOT NULL
AND EXISTS (
SELECT 1 FROM CMS.Transactions t
WHERE t.Id = @ExistingTxId AND t.IsDeleted = 0 AND t.PaymentStatus = 0
)
BEGIN
SET @TxId = @ExistingTxId;
SET @ReusedTx = @ReusedTx + 1;
END
ELSE
BEGIN
INSERT INTO CMS.Transactions
(
Amount, Description, PaymentStatus, PaymentDate, RefId, Type,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@Amount,
CONCAT(
N'عضویت دستی باشگاه مشتریان - ',
ISNULL(@Description, N'backfill UPP'),
N' - مرجع: ',
ISNULL(@ReferenceNumber, CONCAT(N'MP-', @ManualPaymentId))
),
0, -- Success
@PurchasedAt,
ISNULL(@ReferenceNumber, CONCAT(N'UPP-MP-FIX-', @ManualPaymentId)),
2, -- DepositExternal1
@PurchasedAt, NULL, @PurchasedAt, NULL, 0
);
SET @TxId = SCOPE_IDENTITY();
SET @CreatedTx = @CreatedTx + 1;
UPDATE CMS.ManualPayments
SET
TransactionId = @TxId,
LastModified = @Now
WHERE Id = @ManualPaymentId
AND (TransactionId IS NULL OR TransactionId <> @TxId);
END
INSERT INTO CMS.UserPackagePurchases
(
UserId, PackageId, PurchaseMethod, PurchasedAt, Amount,
OrderId, TransactionId, Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@UserId, @PackageId, 3, -- Manual
@PurchasedAt, @Amount,
NULL, @TxId, @PurchasedAt, NULL, @PurchasedAt, NULL, 0
);
-- Align user flag (only None/DirectPurchase → Manual; leave real Daya=1 alone)
UPDATE CMS.Users
SET
PackagePurchaseMethod = 3,
LastModified = @Now
WHERE Id = @UserId
AND IsDeleted = 0
AND PackagePurchaseMethod IN (0, 2);
SET @CreatedUpp = @CreatedUpp + 1;
FETCH NEXT FROM cur INTO
@UserId, @ManualPaymentId, @ExistingTxId, @PackageId, @Amount, @PurchasedAt, @ReferenceNumber, @Description;
END
CLOSE cur;
DEALLOCATE cur;
DROP TABLE #UppToCreate;
PRINT CONCAT('UPP created: ', @CreatedUpp,
' | Tx reused: ', @ReusedTx,
' | Tx created: ', @CreatedTx);
------------------------------------------------------------
-- 6) UserWalletHistory for every FixUser that has a Tx but no history
-- Runs AFTER step 5 so the ~18 new Txs are included.
-- The ~6 with existing UPP/Tx only get history (no new Tx if resolved).
-- NO wallet Balance/Discount charge.
------------------------------------------------------------
;WITH BestManualAll AS (
SELECT
mp.UserId,
mp.Id AS ManualPaymentId,
mp.Amount,
mp.TransactionId,
mp.ReferenceNumber,
mp.Description,
COALESCE(mp.ApprovedAt, mp.Created) AS PurchasedAt,
ROW_NUMBER() OVER (
PARTITION BY mp.UserId
ORDER BY
CASE WHEN mp.Status = 1 THEN 0 ELSE 1 END,
CASE WHEN mp.TransactionId IS NOT NULL THEN 0 ELSE 1 END,
COALESCE(mp.ApprovedAt, mp.Created) DESC,
mp.Id DESC
) AS rn
FROM CMS.ManualPayments mp
INNER JOIN @FixUsers f ON f.UserId = mp.UserId
WHERE mp.IsDeleted = 0
),
Resolved AS (
SELECT
bm.UserId,
bm.ManualPaymentId,
bm.ReferenceNumber,
bm.Description,
bm.PurchasedAt,
COALESCE(NULLIF(cm.LastPackageId, 0), NULLIF(cm.FirstPackageId, 0), @BasePackageId) AS PackageId,
CASE
WHEN bm.Amount IS NOT NULL AND bm.Amount > 0 THEN bm.Amount
ELSE COALESCE(p.Price, @BasePackagePrice)
END AS Amount,
COALESCE(
CASE
WHEN bm.TransactionId IS NOT NULL
AND EXISTS (
SELECT 1 FROM CMS.Transactions t
WHERE t.Id = bm.TransactionId AND t.IsDeleted = 0 AND t.PaymentStatus = 0
)
THEN bm.TransactionId
END,
(
SELECT TOP (1) upp.TransactionId
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = bm.UserId AND upp.IsDeleted = 0 AND upp.TransactionId IS NOT NULL
ORDER BY upp.Id DESC
),
(
SELECT TOP (1) t.Id
FROM CMS.Transactions t
WHERE t.IsDeleted = 0 AND t.PaymentStatus = 0
AND bm.ReferenceNumber IS NOT NULL
AND t.RefId = bm.ReferenceNumber
ORDER BY t.Id DESC
)
) AS TxId
FROM BestManualAll bm
LEFT JOIN CMS.ClubMemberships cm
ON cm.UserId = bm.UserId AND cm.IsDeleted = 0
LEFT JOIN CMS.Packages p
ON p.Id = COALESCE(NULLIF(cm.LastPackageId, 0), NULLIF(cm.FirstPackageId, 0), @BasePackageId)
AND p.IsDeleted = 0
WHERE bm.rn = 1
)
SELECT
r.UserId,
r.ManualPaymentId,
r.TxId,
r.PackageId,
r.Amount,
COALESCE(t.PaymentDate, r.PurchasedAt) AS HistoryAt,
CAST(ROUND(r.Amount * COALESCE(p.DiscountMultiplier, 2.0), 0) AS BIGINT) AS DiscountAmount,
w.Id AS WalletId,
COALESCE(w.NetworkBalance, 0) AS NetworkBalance
INTO #WalletHistoryToCreate
FROM Resolved r
LEFT JOIN CMS.Transactions t ON t.Id = r.TxId AND t.IsDeleted = 0
LEFT JOIN CMS.Packages p ON p.Id = r.PackageId AND p.IsDeleted = 0
LEFT JOIN CMS.UserWallets w ON w.UserId = r.UserId AND w.IsDeleted = 0
WHERE r.TxId IS NOT NULL
AND r.Amount > 0
AND r.PackageId IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets wh ON wh.Id = h.WalletId AND wh.IsDeleted = 0
WHERE wh.UserId = r.UserId
AND h.IsDeleted = 0
AND h.RefrenceId = r.TxId
);
DECLARE @MissingWhCount INT = (SELECT COUNT(*) FROM #WalletHistoryToCreate);
PRINT CONCAT('Missing WalletHistory (after UPP/Tx step — expect ~6 old + ~18 new): ', @MissingWhCount);
SELECT
UserId, ManualPaymentId, TxId, PackageId, Amount, DiscountAmount, HistoryAt, WalletId,
N'CREATE_HISTORY_ONLY' AS PlanAction
FROM #WalletHistoryToCreate
ORDER BY UserId;
DECLARE @WalletId BIGINT;
DECLARE @DiscountAmount BIGINT;
DECLARE @HistoryAt DATETIME2;
DECLARE @NetworkBalance BIGINT;
DECLARE @CreatedWh INT = 0;
DECLARE curWh CURSOR LOCAL FAST_FORWARD FOR
SELECT UserId, ManualPaymentId, TxId, PackageId, Amount, DiscountAmount, HistoryAt, WalletId, NetworkBalance
FROM #WalletHistoryToCreate;
OPEN curWh;
FETCH NEXT FROM curWh INTO
@UserId, @ManualPaymentId, @TxId, @PackageId, @Amount, @DiscountAmount, @HistoryAt, @WalletId, @NetworkBalance;
WHILE @@FETCH_STATUS = 0
BEGIN
-- Keep MP linked to resolved Tx when it was found via UPP / RefId
UPDATE CMS.ManualPayments
SET TransactionId = @TxId, LastModified = @Now
WHERE Id = @ManualPaymentId
AND (TransactionId IS NULL OR TransactionId <> @TxId);
IF @WalletId IS NULL
BEGIN
INSERT INTO CMS.UserWallets
(UserId, Balance, NetworkBalance, DiscountBalance, WalletMode, Created, LastModified, IsDeleted)
VALUES
(@UserId, 0, 0, 0, 0, @HistoryAt, @HistoryAt, 0);
SET @WalletId = SCOPE_IDENTITY();
SET @NetworkBalance = 0;
END
INSERT INTO CMS.UserWalletHistories
(
WalletId, CurrentBalance, ChangeValue, CurrentNetworkBalance, ChangeNerworkValue,
CurrentDiscountBalance, ChangeDiscountValue, IsIncrease, RefrenceId, PackageId,
Created, CreatedBy, LastModified, LastModifiedBy, IsDeleted
)
VALUES
(
@WalletId,
0,
@Amount,
@NetworkBalance,
0,
0,
@DiscountAmount,
1,
@TxId,
@PackageId,
@HistoryAt,
NULL,
@HistoryAt,
NULL,
0
);
SET @CreatedWh = @CreatedWh + 1;
FETCH NEXT FROM curWh INTO
@UserId, @ManualPaymentId, @TxId, @PackageId, @Amount, @DiscountAmount, @HistoryAt, @WalletId, @NetworkBalance;
END
CLOSE curWh;
DEALLOCATE curWh;
DROP TABLE #WalletHistoryToCreate;
PRINT CONCAT('WalletHistory created (no charge): ', @CreatedWh);
------------------------------------------------------------
-- Verify after
------------------------------------------------------------
SELECT 'Users still DirectPurchase (in scope)' AS CheckName, COUNT(*) AS Cnt
FROM CMS.Users u
INNER JOIN @FixUsers f ON f.UserId = u.Id
WHERE u.IsDeleted = 0 AND u.PackagePurchaseMethod = 2
UNION ALL
SELECT 'UPP still DirectPurchase (in scope)', COUNT(*)
FROM CMS.UserPackagePurchases upp
INNER JOIN @FixUsers f ON f.UserId = upp.UserId
WHERE upp.IsDeleted = 0 AND upp.PurchaseMethod = 2
UNION ALL
SELECT 'Club still DirectPurchase (in scope)', COUNT(*)
FROM CMS.ClubMemberships m
INNER JOIN @FixUsers f ON f.UserId = m.UserId
WHERE m.IsDeleted = 0 AND m.PurchaseMethod = 2
UNION ALL
SELECT 'Cycle still DirectPurchase (in scope)', COUNT(*)
FROM CMS.ClubMembershipCycles c
INNER JOIN @FixUsers f ON f.UserId = c.UserId
WHERE c.IsDeleted = 0 AND c.PurchaseMethod = 2
UNION ALL
SELECT 'Still missing any UPP (in scope)', COUNT(*)
FROM @FixUsers f
WHERE NOT EXISTS (
SELECT 1 FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = f.UserId AND upp.IsDeleted = 0
)
UNION ALL
SELECT 'Still missing WalletHistory for resolved Tx', COUNT(*)
FROM @FixUsers f
OUTER APPLY (
SELECT TOP (1)
mp.TransactionId AS MpTxId,
mp.ReferenceNumber
FROM CMS.ManualPayments mp
WHERE mp.UserId = f.UserId AND mp.IsDeleted = 0
ORDER BY
CASE WHEN mp.Status = 1 THEN 0 ELSE 1 END,
CASE WHEN mp.TransactionId IS NOT NULL THEN 0 ELSE 1 END,
COALESCE(mp.ApprovedAt, mp.Created) DESC,
mp.Id DESC
) mp
OUTER APPLY (
SELECT TOP (1) upp.TransactionId AS UppTxId
FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = f.UserId AND upp.IsDeleted = 0 AND upp.TransactionId IS NOT NULL
ORDER BY upp.Id DESC
) upp
CROSS APPLY (
SELECT COALESCE(
CASE
WHEN mp.MpTxId IS NOT NULL
AND EXISTS (
SELECT 1 FROM CMS.Transactions t
WHERE t.Id = mp.MpTxId AND t.IsDeleted = 0 AND t.PaymentStatus = 0
)
THEN mp.MpTxId
END,
upp.UppTxId,
(
SELECT TOP (1) t.Id
FROM CMS.Transactions t
WHERE t.IsDeleted = 0 AND t.PaymentStatus = 0
AND mp.ReferenceNumber IS NOT NULL
AND t.RefId = mp.ReferenceNumber
ORDER BY t.Id DESC
)
) AS TxId
) tx
WHERE tx.TxId IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM CMS.UserWalletHistories h
INNER JOIN CMS.UserWallets w ON w.Id = h.WalletId AND w.IsDeleted = 0
WHERE w.UserId = f.UserId
AND h.IsDeleted = 0
AND h.RefrenceId = tx.TxId
);
SELECT TOP (50)
u.Id AS UserId,
u.Mobile,
u.PackagePurchaseMethod AS UserMethod,
m.PurchaseMethod AS ClubMethod,
(SELECT COUNT(*) FROM CMS.UserPackagePurchases upp
WHERE upp.UserId = u.Id AND upp.IsDeleted = 0 AND upp.PurchaseMethod = 3) AS UppManualCnt
FROM CMS.Users u
INNER JOIN @FixUsers f ON f.UserId = u.Id
LEFT JOIN CMS.ClubMemberships m ON m.UserId = u.Id AND m.IsDeleted = 0
WHERE u.IsDeleted = 0
ORDER BY u.Id;
-- Uncomment to apply:
-- COMMIT;
ROLLBACK;
PRINT 'Rolled back (safety). Uncomment COMMIT and remove ROLLBACK to apply.';

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