109 Commits

Author SHA1 Message Date
masoodafar-web c814cd9118 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 8m2s
2026-08-24 00:10:39 +03:30
masoodafar-web 2d23dbc798 feat(withdrawal): normalize IBAN format in withdrawal requests
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m38s
- Added IbanNormalizer to validate and normalize IBAN numbers in RequestWithdrawalCommandHandler and UserWalletService.
- Implemented error handling for invalid IBAN formats, ensuring compliance with expected standards.
- Updated relevant methods to handle normalized IBANs for cash withdrawal requests.
2026-08-23 23:01:17 +03:30
masoodafar-web 8ec0910cdf feat(jwt): enhance JWT token generation with user address check
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m36s
- Updated GenerateJwtTokenService to include IApplicationDbContext for accessing user addresses.
- Added logic to check if a user has an address and included this information as a claim in the generated JWT token.
- Improved overall token generation process by incorporating additional user data for better context.
2026-08-23 22:32:36 +03:30
masoodafar-web e882f09955 feat(user): enhance GetAllUserByFilter query response with additional fields and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m6s
- Added DefaultAddress and DefaultPostalCode properties to GetAllUserByFilterResponseDto for improved user data representation.
- Updated GetAllUserByFilterQueryHandler to populate new response fields with relevant data.
- Modified Protobuf definitions to include new fields in GetAllUserByFilterResponseModel.
- Bumped Protobuf project version to reflect the addition of new features.
2026-08-14 03:18:58 +03:30
masoodafar-web 436e5d7aca Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 9m50s
2026-08-09 02:33:38 +03:30
masoodafar-web 6a04a6a270 feat(package): enhance pagination logic in GetCustomerPackagePurchaseRollupQueryHandler
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m42s
- Updated pagination handling to allow for exporting all results when PageSize is set to 0.
- Adjusted page size and number calculations to accommodate the new export functionality.
- Improved clarity in pagination logic by distinguishing between export and paginated results.
2026-08-09 00:40:25 +03:30
masoodafar-web 427d2cce1d feat(package): update package purchase methods and enhance Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m45s
- Changed package purchase method from DirectPurchase to Manual in multiple command handlers to reflect new business logic.
- Added Manual purchase method to PackagePurchaseMethod enum for clarity.
- Introduced new RPC method GetCustomerPackagePurchaseRollup in Protobuf for summarizing customer package purchases.
- Updated UserPackagePurchaseProfile and UserPackagePurchaseService to support new rollup functionality.
- Bumped Protobuf project version to accommodate new features.
2026-08-08 21:59:14 +03:30
masoodafar-web fb9e3d0109 feat(order): refactor order data mapping for improved clarity and structure
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 13m4s
- Updated GetAllDiscountOrdersQueryHandler to enhance order data mapping by using a more structured approach with AdminOrderDto.
- Refactored user full name construction in GetCustomerOrderQueryHandler and GetCustomerOrdersQueryHandler to utilize UserDisplayName for better readability.
- Added necessary using directives for consistency across query handlers.
2026-08-07 18:39:42 +03:30
masoodafar-web 6c90e4caf0 feat(order): enhance order status management and Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m46s
- Added AdminNotes handling in UpdateOrderStatusCommandHandler to include delivery descriptions.
- Updated DeliveryStatus enum to include ReadyForOfficePickup.
- Enhanced GetCustomerOrderHistoryQueryHandler and UserOrderService to support new delivery status.
- Updated Protobuf definitions for DeliveryStatus to reflect changes.
- Bumped Protobuf project version to accommodate new features.
2026-08-07 17:25:57 +03:30
masoodafar-web 40da1bd8df feat(order): enhance order details with user contact information and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m33s
- Added Phone property to UserAddressDto for capturing the user's mobile number.
- Updated GetOrderByIdQueryHandler to include user mobile in order details.
- Enhanced GetCustomerOrderQueryHandler and GetCustomerOrderResponseDto to include PostalCode and UserMobile.
- Modified userorder.proto to add postal_code and user_mobile fields in GetUserOrderResponse.
- Bumped Protobuf project version to reflect the addition of new features.
2026-08-07 15:48:04 +03:30
masoodafar-web 053c5a7c4f Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 17m27s
2026-08-07 14:38:07 +03:30
masoodafar-web 3810146651 feat(wallet): enforce club membership validation for wallet charge operations
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 19m57s
- Added club membership inclusion in user retrieval for ChargeCreditWallet and VerifyCreditWalletCharge command handlers.
- Implemented validation to ensure users have an active club membership before allowing wallet charges, throwing a BadRequestException if not.
- Updated UserWalletService to handle BadRequestException and return appropriate error messages in responses.
2026-08-06 23:59:36 +03:30
masoodafar-web 82944e11d0 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-07-26 00:52:02 +03:30
masoodafar-web ce98efdd68 feat(sorting): implement ApplyOrderWithInStockPriority method for enhanced product sorting
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Added ApplyOrderWithInStockPriority method to prioritize in-stock products in queries.
- Updated GetDiscountProductsQueryHandler and GetCustomerProductsByFilterQueryHandler to utilize the new sorting method, ensuring in-stock items are listed first followed by user-defined sorting or creation date.
- Improved overall query handling for product availability and sorting logic.
2026-07-26 00:17:44 +03:30
masoodafar-web d232399f43 feat(wallet): add manual credit wallet charge functionality and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced ManualCreditWalletCharge enum value to TransactionType for clarity.
- Added AdminManualCreditCharge and GetManualCreditCharges RPC methods in UserWalletService for handling manual credit charges by admin.
- Created corresponding request and response messages for manual credit charge operations in Protobuf.
- Updated Protobuf project version to reflect the addition of new features.
2026-07-25 23:55:08 +03:30
masoodafar-web bb20ad2c4e Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-06-30 02:28:32 +03:30
masoodafar-web 246d893b62 feat(discount): add InStock filter to discount product queries and mappings
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced InStock property to GetDiscountProductsQuery and GetCustomerProductsByFilterQuery for filtering based on product availability.
- Updated query handlers to apply InStock filtering logic in both discount and customer product queries.
- Enhanced mapping configurations to include InStock in the response DTOs.
- Bumped Protobuf project version to reflect the addition of new features.
2026-06-30 00:16:28 +03:30
masoodafar-web 99aab3beff Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 19m40s
2026-06-26 19:30:03 +03:30
masoodafar-web 728f28f525 fix(config): update FrontOfficeBaseUrl in configuration files
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Changed FrontOfficeBaseUrl from "https://kbs1.ir" to "https://frontoffice.se.kbs1.ir" in both cms-config.yaml and appsettings.Staging.json for consistency across environments.
2026-06-26 17:49:42 +03:30
masoodafar-web 89af07c122 feat(config): add CMS and Front Office base URLs to configuration files
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Introduced CmsBaseUrl and FrontOfficeBaseUrl properties in both cms-config.yaml and appsettings.Staging.json for improved service integration.
- Ensured consistency across configuration files for easier management of base URLs.
2026-06-26 17:12:43 +03:30
masoodafar-web 58aeddf004 feat(transactions): enhance transaction filtering and summary capabilities
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Added new date range filters (CreatedFrom, CreatedTo, PaymentDateFrom, PaymentDateTo) to GetAllTransactionsByFilterQuery for improved transaction querying.
- Updated GetAllTransactionsByFilterQueryHandler to apply new filters and return detailed transaction models.
- Introduced GetTransactionSummary RPC method in Protobuf for summarizing transaction data.
- Enhanced mapping configurations to accommodate new filter properties and response models.
- Bumped Protobuf project version to reflect the addition of new features.
2026-06-26 16:33:20 +03:30
masoodafar-web 6c3fbe43b1 feat(wallet): add credit wallet functionality and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m49s
- Introduced new CreditWalletCharge enum value to TransactionType for clarity.
- Implemented InitiateCreditCharge and VerifyCreditCharge methods in UserWalletService for handling credit wallet transactions.
- Updated ZarinpalReconciliationJob to support credit wallet payment verification.
- Enhanced Protobuf definitions with new messages and RPC methods for credit wallet operations.
- Bumped Protobuf project version to reflect the addition of new features.
2026-06-26 16:11:18 +03:30
masoodafar-web 7570c39e65 refactor(extensions): improve sorting logic in ApplyOrder method
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 21m1s
- Simplified the default sorting approach by directly returning the ordered source.
- Introduced NormalizeSortBy method to handle dynamic LINQ sorting, accommodating leading '+' and '-' for ascending and descending order respectively.
- Enhanced code clarity and maintainability by reducing unnecessary variable assignments.
2026-06-23 00:11:37 +03:30
masoodafar-web 46ac1d817e refactor(mappings): streamline mapping configurations for user package purchases and payment transactions
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 23m14s
- Updated mapping configurations in UserPackagePurchaseProfile and PaymentTransactionProfile to utilize new Protobuf definitions for improved clarity and consistency.
- Simplified filter mappings and removed redundant mapping methods to enhance maintainability.
- Ensured alignment of all relevant profiles with the latest filter structures and DTOs.
2026-06-22 22:54:25 +03:30
masoodafar-web db20d95b43 refactor(mappings): update filter mappings for club membership and payment transaction profiles
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Renamed filter types in ClubMembershipCycleHistoryProfile, ClubMembershipCycleProfile, PaymentTransactionProfile, UserPackagePurchaseProfile to improve clarity and consistency.
- Adjusted mapping methods to utilize new filter type aliases for better readability and maintainability.
- Ensured all relevant profiles are aligned with the updated filter structures.
2026-06-22 22:36:33 +03:30
masoodafar-web f8794bbb63 feat(club-membership): enhance club membership history queries and responses
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 17m54s
- Added filtering capabilities to GetClubMembershipHistoryQuery, allowing optional filters for UserId, ClubMembershipId, Action, and date range.
- Updated GetClubMembershipHistoryQueryHandler to utilize the new filter structure.
- Enhanced GetAllClubMembershipsQueryHandler to include LastPackage details in the response.
- Introduced new properties in GetAllClubMembershipsResponseModel for PackageId and PackageName.
- Updated Protobuf definitions to reflect changes in club membership history queries and responses.
- Refactored mapping configurations to accommodate new DTOs and filters.
2026-06-22 22:19:44 +03:30
masoodafar-web 19be5dc9dc Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 16m51s
2026-06-20 23:45:47 +03:30
masoodafar-web 6395f63ab5 Merge branch 'kub-stage' of https://git.se.kbs1.ir/admin/CMS into kub-stage
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 16m9s
2026-06-20 23:31:31 +03:30
masoodafar-web 758640cce3 feat(wallet): add current discount balance and change discount value to wallet history response DTOs and Protobuf definitions 2026-06-20 23:31:29 +03:30
m-moghaddam 5c1e3cf601 Merge remote-tracking branch 'origin/kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 19m16s
2026-06-20 17:28:19 +03:30
m-moghaddam 37820ea257 feat(db): add SQL script to fix incorrect bulk credit payouts in user wallet histories
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 19m38s
Introduced a new SQL script to identify and correct erroneous bulk credit entries in user wallet histories. The script allows for a dry run to preview changes before applying fixes, ensuring accurate balance adjustments and removal of incorrect records.
2026-06-20 17:27:23 +03:30
m-moghaddam 5f8e30905d refactor(commission): streamline bulk credit payouts handling by removing unused wallet loading and updating payout history logic
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 17m51s
Removed unnecessary loading of user wallets and adjusted payout history creation to reflect accurate amounts before and after payment. Updated comments for clarity.
2026-06-20 16:11:37 +03:30
masoodafar-web d82b7c28a8 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 20m50s
2026-06-19 02:37:34 +03:30
masoodafar-web 02b2966e96 fix(cms-deployment): change imagePullPolicy to Always for cms container and clean up unused images during deployment
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 21m47s
2026-06-19 01:56:43 +03:30
masoodafar-web 29fa7ff435 fix(cms-deployment): change imagePullPolicy to IfNotPresent for cms container
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 19m13s
2026-06-14 00:09:43 +03:30
masoodafar-web 3df738e496 Merge branches 'kub-stage' and 'production' of https://git.se.kbs1.ir/admin/CMS into production
Build and Deploy to Production / build-and-deploy (push) Successful in 11m1s
2026-06-08 22:47:48 +03:30
masoodafar-web dba0b63a70 refactor(payment): unify package verify for callback and reconciliation job
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m40s
Extract VerifyUserPackagePurchasePaymentCommand with idempotency, DB lock,
and IUserPaymentLock so ZarinpalReconciliationJob cannot double-credit wallets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 22:44:12 +03:30
masoodafar-web 3a9c3438e6 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 12m7s
Deploy DiscountOrderService build fix.
2026-06-08 02:30:01 +03:30
masoodafar-web 541b0c2ce9 fix(build): add missing System.Linq import in DiscountOrderService
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 14m42s
Fixes CI compile error CS1061 on DbSet.Where; remove duplicate using in UserOrderService.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 02:29:55 +03:30
masoodafar-web 12ebdc9e87 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
Deploy blog slug auto-generation fix to production.
2026-06-08 02:06:50 +03:30
masoodafar-web 121291eeed fix(blog): auto-generate slug when admin leaves it empty
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
Allow optional slug on create/update, generate from title with unique fallback,
so BackOffice can publish posts without manual slug entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 02:06:30 +03:30
masoodafar-web 9f1bc2bcdb Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 15m25s
Deploy payment idempotency fixes and per-user payment locks to production.
2026-06-08 02:00:30 +03:30
masoodafar-web d22eb1617f feat(payment): add per-user in-memory lock for gateway operations
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 9m58s
Introduce IUserPaymentLock to serialize payment initiate and verify flows
per user, preventing concurrent duplicate gateway requests across services.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 01:55:00 +03:30
masoodafar-web f08d3ac78f fix(payment): prevent duplicate wallet credit on package verify
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 11m29s
Add idempotent verify with serializable transaction and wallet-history check
to stop concurrent callback/race from charging the wallet twice.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 01:32:47 +03:30
masoodafar-web 354ba1dd1b Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 8m37s
2026-05-29 19:59:05 +03:30
masoodafar-web 4f2d313d5e fix(UserOrderService): update comment to reflect correct threshold value for Magic Wallet
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m50s
2026-05-29 19:58:36 +03:30
masoodafar-web 034679d915 fix(ZarinpalReconciliationJob): correct typo in logging method for unverified list
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 16m38s
2026-05-21 20:21:21 +03:30
masoodafar-web 3b9958a700 fix(ZarinpalReconciliationJob): correct typo in logging method for unverified list
Build and Deploy to Production / build-and-deploy (push) Successful in 19m55s
2026-05-21 20:20:38 +03:30
masoodafar-web 9c562b12c7 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-05-21 20:19:43 +03:30
masoodafar-web 6414e5663a fix(ZarinpalReconciliationJob): log information when unverified list is empty
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-05-21 20:19:32 +03:30
masoodafar-web 3c9957463d Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 14m52s
2026-05-21 19:27:56 +03:30
masoodafar-web 90d4f65de4 chore: add missing using directives for System.Collections.Generic in ZarinpalReconciliationJob and ZarinPalPaymentService files
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 20m42s
2026-05-21 19:27:30 +03:30
masoodafar-web 4df71107a9 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 12m46s
2026-05-21 18:27:04 +03:30
masoodafar-web bc52240a41 feat(payment): add GetUnverifiedAuthoritiesAsync method to IPaymentGatewayService and ZarinPalPaymentService for payment reconciliation
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 8m7s
- Introduced GetUnverifiedAuthoritiesAsync method to retrieve authorities with unverified payments.
- Implemented ZarinPal-specific logic for fetching unverified authorities from the new endpoint.
- Added background job for Zarinpal payment reconciliation every 30 minutes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 18:26:49 +03:30
masoodafar-web a7fc959d3e Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 14m3s
2026-05-18 01:56:29 +03:30
masoodafar-web aac0d4271d feat(wallets): add DiscountBalance to GetAllUserWalletByFilter response; bump proto to 0.0.199
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m40s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 01:13:56 +03:30
masoodafar-web d96558aa4b Revert "feat(commission): add BulkCreditPayouts — credit wallets for pending payouts"
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
This reverts commit a2d13c72c3.
2026-05-18 00:38:40 +03:30
masoodafar-web da1a2fcf8e feat(commission): add BulkCreditPayouts — credit wallets for pending payouts
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m35s
- commission.proto: add BulkCreditPayouts rpc + request/response messages; bump version to 0.0.198
- BulkCreditPayoutsCommand + Handler: load all Pending payouts for given week, credit UserWallet.Balance for each user, create UserWalletHistory + CommissionPayoutHistory records, set Status=Paid + PaidAt
- CommissionService: wire up BulkCreditPayouts gRPC method
- CommissionProfile: add Mapster mappings for request/result ↔ proto types

Previously ProcessUserPayouts created records with Status=Pending but never credited wallets, making CustomerWithdrawBalance unreachable (it requires Status==Paid).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:38:25 +03:30
masoodafar-web a2d13c72c3 feat(commission): add BulkCreditPayouts — credit wallets for pending payouts
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
- commission.proto: add BulkCreditPayouts rpc + request/response messages; bump version to 0.0.198
- BulkCreditPayoutsCommand + Handler: load all Pending payouts for given week, credit UserWallet.Balance for each user, create UserWalletHistory + CommissionPayoutHistory records, set Status=Paid + PaidAt
- CommissionService: wire up BulkCreditPayouts gRPC method
- CommissionProfile: add Mapster mappings for request/result ↔ proto types

Previously ProcessUserPayouts created records with Status=Pending but never credited wallets, making CustomerWithdrawBalance unreachable (it requires Status==Paid).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:36:05 +03:30
masoodafar-web 4e8caa6e2c Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 19m44s
2026-05-18 00:12:13 +03:30
masoodafar-web cdd124ac25 fix: rename cancellationToken to ct in ProcessReturnAsync call
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m21s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 22:38:40 +03:30
masoodafar-web 4d7932f335 fix(regular-store): deduct inventory and restore on cancel
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 19m38s
- UserOrderService.SubmitShopBuyOrder: inject IInventoryService; validate
  available stock per item before any financial operation; call ConfirmSaleAsync
  and increment Product.SaleCount after order is persisted.
- CancelOrderByAdminCommandHandler: inject IInventoryService; include
  FactorDetails in query; call ProcessReturnAsync for each item so stock
  is restored when an order is cancelled by admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 21:57:48 +03:30
masoodafar-web 18a2299d93 fix: update MagicWallet thresholds for consistency with business logic
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m11s
- Adjust MagicWalletEntryThreshold from 1,000,000 to 100,000
- Update MagicWalletDefaultMaxDeposit from 1,000,000,000 to 100,000,000
- Change MagicWalletDefaultMaxCredit from 2,500,000,000 to 250,000,000
- Modify comments in UserOrderService to reflect updated threshold values
2026-05-14 03:12:45 +03:30
masoodafar-web 37a017998c Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 9m24s
2026-05-14 01:13:03 +03:30
masoodafar-web 6e60ca7c25 chore: bump CMSMicroservice.Protobuf version to 0.0.197
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m42s
2026-05-13 22:21:22 +03:30
masoodafar-web fd8b37a6b1 docs: add note on TLS certificate requirements for cms-tls in Ingress configuration
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m14s
2026-05-13 22:05:26 +03:30
masoodafar-web 683ed370b5 feat: extend discount product gRPC API with sort_by and sale_count
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m11s
- Add sort_by (StringValue) to GetDiscountProductsRequest proto to enable dynamic sorting
- Add sale_count (int32) to DiscountProductDto proto for top-seller exposure
- Update GetDiscountProductsQuery with SortBy property
- Update GetDiscountProductsQueryHandler to use ApplyOrder(SortBy) with fallback to Created desc; project SaleCount in SELECT
- Add SortBy and SaleCount Mapster mappings in DiscountProductProfile
- Bump CMSMicroservice.Protobuf version to 0.0.196

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 19:47:56 +03:30
masoodafar-web 16acb8f3b5 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 7m7s
2026-05-10 21:37:51 +03:30
masoodafar-web 98c2875ae2 feat: simplify UserId handling in GetOrderById method for improved clarity
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m12s
2026-05-10 21:37:30 +03:30
masoodafar-web 34d2354fd4 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 9m37s
2026-05-05 01:54:44 +03:30
masoodafar-web 65db839642 feat: update GetOrderByIdQuery to allow nullable UserId and adjust query handling in GetOrderByIdQueryHandler
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m43s
2026-05-05 01:54:33 +03:30
masoodafar-web f7d6b0e3e7 feat: cast Created field to datetimeoffset in sp_GetUserWeeklyBalances for improved date handling
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m46s
2026-05-05 01:35:07 +03:30
masoodafar-web be2b22c50a Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 10m25s
2026-05-04 23:55:16 +03:30
masoodafar-web b905c308c1 feat: enhance sp_CalculateWeeklyBalances to track package activity and optimize member calculations
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m21s
2026-05-04 23:54:13 +03:30
masoodafar-web d09db638cc feat: enhance sp_CalculateWeeklyCommissionPool to include IsDeleted filter and update commission calculations for package-specific pools
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m44s
2026-05-04 18:44:28 +03:30
masoodafar-web 41e7f29a0d feat: add PackageId to CalculateWeeklyCommissionPoolCommand and related logic for package-specific calculations
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m43s
2026-05-04 02:31:26 +03:30
masoodafar-web c4409491c1 feat: update stored procedure to use UserWalletHistories and include PackageId in balance checks
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m19s
2026-05-04 01:28:04 +03:30
masoodafar-web e4279f3d05 Add migration to rename index and create stored procedure for user weekly balances
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m55s
- Added migration to rename index from IX_NetworkWeeklyBalances_PackageId to IX_NetworkWeeklyBalance_PackageId in the NetworkWeeklyBalances table.
- Created stored procedure sp_GetUserWeeklyBalances to retrieve weekly balances with pagination and various filters.
2026-05-04 00:36:31 +03:30
masoodafar-web b260fd56fa Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 10m7s
2026-05-04 00:04:36 +03:30
masoodafar-web c5dd1a2ef5 feat: improve package retrieval logic in AcceptClubMembershipContract and ActivateClubMembership handlers
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m0s
2026-05-03 22:03:47 +03:30
masoodafar-web 1e3ba03c5c Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 8m34s
2026-05-01 00:56:21 +03:30
masoodafar-web eeb837f32e feat: enhance inventory release logic in ExpirePendingOrdersService and InventoryService
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m27s
2026-05-01 00:56:00 +03:30
masoodafar-web ee7d29015f Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-05-01 00:47:44 +03:30
masoodafar-web 624ab7ea9f feat: conditionally enable InventoryInitializer service and update appsettings for seed workers
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-05-01 00:47:32 +03:30
masoodafar-web d8a3b49f4d feat: add GetNetworkTree stored procedure for network data retrieval
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-05-01 00:28:25 +03:30
masoodafar-web b053d86006 feat: Add GetNetworkTree stored procedure for binary network retrieval with activation logic
Build and Deploy to Production / build-and-deploy (push) Successful in 8m27s
2026-05-01 00:27:44 +03:30
masoodafar-web 15044c66cb Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-05-01 00:15:48 +03:30
masoodafar-web 8518c7b3ec feat: Enhance club membership activation and commission pool validation
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m9s
- Updated AcceptClubMembershipContractCommandHandler to set PackagePurchasedAt to the contract-signing time, ensuring accurate pool charging.
- Modified ActivateClubMembershipCommand to allow specifying a PackageId for forced activation, defaulting to the base package if not provided.
- Improved ActivateClubMembershipCommandHandler to handle new and existing memberships differently regarding pool charging.
- Added validation logic in GetWeeklyCommissionPoolQueryHandler to compare TotalPoolAmount against actual activations.
- Introduced validation fields in WeeklyCommissionPoolDto for tracking discrepancies and expected amounts.
- Updated stored procedures to ensure accurate calculations based on package purchases and to handle empty pools.
- Enhanced protobuf definitions to include validation fields for commission pools and network tree nodes.
- Added mapping for new fields in CommissionProfile and NetworkMembershipProfile.
- Created DiscountProductProfile for mapping discount product requests and responses.
2026-05-01 00:07:05 +03:30
masoodafar-web 0983cdf88f Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 8m56s
2026-04-30 04:27:43 +03:30
masoodafar-web 789aa0b870 fix: update response message for non-existent user during registration process
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m42s
2026-04-30 03:55:03 +03:30
masoodafar-web b4e7902870 fix: update Kavenegar API key in appsettings for staging environment
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m25s
2026-04-30 03:44:38 +03:30
masoodafar-web aa7f77bfec fix: return 'user not found' for login purpose instead of requiring referral code 2026-04-30 03:42:53 +03:30
masoodafar-web a4d04cb7bf fix: update Kavenegar API key in appsettings for staging environment
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m19s
2026-04-30 03:36:10 +03:30
masoodafar-web 85591aec30 fix: update Kavenegar API key in appsettings for staging environment
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m0s
2026-04-30 02:33:54 +03:30
masoodafar-web e93fad3b3d fix: update Kavenegar API key in appsettings for SMS provider
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-04-30 02:31:45 +03:30
masoodafar-web 04e1a83838 feat: enhance OTP verification logic to support master code bypass and improve error handling 2026-04-29 22:58:16 +03:30
masoodafar-web b8120dd1b9 config: add master OTP code to production appsettings 2026-04-29 00:49:55 +03:30
masoodafar-web 7ce5539793 feat: add master OTP bypass for login + referral code field support 2026-04-29 00:49:33 +03:30
masoodafar-web 4a7df2083e feat: add master OTP bypass for login + referral code field support
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 22m37s
2026-04-29 00:49:25 +03:30
masoodafar-web 9a4f1e6401 chore: merge kub-stage into production
Build and Deploy to Production / build-and-deploy (push) Successful in 8m11s
2026-04-28 23:28:44 +03:30
masoodafar-web e77b5f9658 fix(ci): fix proto publish - remove allow-insecure-connections, add CI=true env, use source name
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 16m36s
2026-04-24 04:43:28 +03:30
masoodafar-web b71ddfaf9b chore(proto): bump version to 0.0.193 for per-package commission fields
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 18m4s
2026-04-24 04:18:41 +03:30
masoodafar-web 4d6d77531d feat(commission): add per-package calculation support
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 15m3s
- SP sp_CalculateWeeklyBalances: added @PackageId, @InputMaxBalancesPerLeg, @InputMaxNetworkLevel params; filters by PackageId
- SP sp_CalculateWeeklyCommissionPool: added @PackageId param; all queries filter by PackageId/WeeklyPoolId for isolation
- ICommissionCalculationStrategy: added optional PackageId param to both methods
- StoredProcedureCommissionCalculationStrategy: loops per-package for both Balance and Pool methods; filters by packageId if provided
- OrmCommissionCalculationStrategy: signature updated to match interface
- TriggerWeeklyCalculationCommand: added PackageId optional field
- TriggerWeeklyCalculationCommandHandler: passes PackageId to strategy
- GetWeeklyCommissionPoolQuery: added optional PackageId filter
- GetWeeklyCommissionPoolQueryHandler: filters pool by PackageId if provided
- GetAllWeeklyPoolsQuery/Handler/DTO: added PackageId filter + PackageTitle in response
- Proto commission.proto: added package_id to TriggerWeeklyCalculationRequest, GetWeeklyCommissionPoolRequest, WeeklyCommissionPoolModel, GetWeeklyCommissionPoolResponse, GetAllWeeklyPoolsRequest
- CommissionProfile: added explicit mappings for TriggerWeeklyCalculation, GetWeeklyCommissionPool, GetAllWeeklyPools

Fixes: SP picks wrong pool when multiple packages per week
Fixes: SP ignores PackageId on ForceRecalculate (now uses WeeklyPoolId)
Fixes: Zero-balance pools not fully recorded (now sets TotalBalances=0, ValuePerBalance=0)
2026-04-24 03:43:20 +03:30
masoodafar-web 091a04f3aa Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 24m9s
2026-03-15 03:19:41 +03:30
masoodafar-web a35e2eeb92 fix: add proxy timeout annotations to CMS ingress
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 23m45s
Add nginx.ingress.kubernetes.io/proxy-read-timeout: 3600
and proxy-send-timeout: 3600 to prevent 504 Gateway Timeout
on gRPC-Web requests through the nginx ingress controller.
2026-03-15 03:12:03 +03:30
masoodafar-web 0a66b09e7b ci: retry build - all NuGet packages pushed to Nexus
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 22m48s
2026-03-12 01:14:07 +03:30
masoodafar-web e946c56631 Revert "fix: bypass broken Nexus proxy, use nuget.org directly"
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 20m7s
This reverts commit 370f735cf2.
2026-03-12 00:06:14 +03:30
masoodafar-web 370f735cf2 fix: bypass broken Nexus proxy, use nuget.org directly
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
Nexus nuget-all proxy returns 500. Replace with direct nuget.org
source. Keep foursat-hosted for proto push step only.
2026-03-12 00:03:42 +03:30
masoodafar-web 0e9d0cad2f feat: manual payment package-based + StockMovement constraint fix
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 7m23s
- ManualPayment: add package_id to proto, handler selects package by ID
- ManualPayment: remove manual amount input, use package price
- ManualPayment: validator now checks PackageId instead of Amount
- StockMovement: fix CHECK constraint (store negative qty for decreases)
- StockMovement: Reserved/Released don't change physical Quantity
- Proto NuGet bumped to 0.0.192
2026-03-11 23:44:28 +03:30
189 changed files with 14441 additions and 730 deletions
+4 -3
View File
@@ -58,7 +58,7 @@ jobs:
- 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 -e CI=true -v $(pwd):/src -w /src \
194.5.195.53:32082/dotnet/sdk:9.0 sh -c ' 194.5.195.53: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"
@@ -67,9 +67,9 @@ 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 "foursat-hosted" \
--api-key "admin:87zH26nbqT" \ --api-key "admin:87zH26nbqT" \
--skip-duplicate --allow-insecure-connections || true --skip-duplicate --configfile src/NuGet.config || true
done done
done done
' '
@@ -95,6 +95,7 @@ jobs:
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 -f /tmp/cms-config.yaml &&
kubectl apply -f /tmp/cms-deployment.yaml && kubectl apply -f /tmp/cms-deployment.yaml &&
crictl rmi ${REGISTRY}/${IMAGE_NAME}:latest 2>/dev/null || true &&
kubectl rollout restart deployment/cms && kubectl rollout restart deployment/cms &&
kubectl rollout status deployment/cms --timeout=180s && kubectl 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
+1
View File
@@ -1,3 +1,4 @@
# CMS Microservice # CMS Microservice
Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md). Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md).
File diff suppressed because it is too large Load Diff
+66 -4
View File
@@ -19,28 +19,89 @@ type: Opaque
stringData: stringData:
appsettings.Production.json: | appsettings.Production.json: |
{ {
"PaymentProvider": "zarinpal",
"ZarinPal": { "ZarinPal": {
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8", "MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": false "UseSandbox": false
}, },
"CmsBaseUrl": "https://cms.kbs2.ir", "CmsBaseUrl": "https://cms.kbs2.ir",
"FrontOfficeBaseUrl": "https://kbs2.ir", "FrontOfficeBaseUrl": "https://kbs2.ir",
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Server=mssql-svc;Database=KBS;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;" "DefaultConnection": "Server=mssql-svc;Database=KBS;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;",
"providerName": "System.Data.SqlClient"
},
"Otp": {
"Secret": "K2w8k1h1mH2Qz1kqWk0c8kQ2Pq8q9H1eE2nqN1qQ8x7M="
},
"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": false,
"CronExpression": "5 0 * * 0"
}
}, },
"SeedWorkers": { "SeedWorkers": {
"MagicWalletCycleSeed": { "MagicWalletCycleSeed": {
"Enabled": true "Enabled": true
} }
}, },
"FileStorage": { "AllowedHosts": "*",
"UploadPath": "/app/Uploads"
},
"Kestrel": { "Kestrel": {
"EndpointDefaults": { "EndpointDefaults": {
"Protocols": "Http2" "Protocols": "Http2"
} }
}, },
"Authentication": {
"Authority": "https://ids.domain.com/",
"Audience": "domain_api"
},
"Seq": { "Seq": {
"ServerUrl": "http://seq-svc:5341", "ServerUrl": "http://seq-svc:5341",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq" "ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
@@ -52,3 +113,4 @@ stringData:
} }
} }
} }
+3 -1
View File
@@ -24,6 +24,8 @@ stringData:
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8", "MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": true "UseSandbox": true
}, },
"CmsBaseUrl": "https://cms.se.kbs1.ir",
"FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir",
"FMS": { "FMS": {
"Address": "https://dl.afrino.co" "Address": "https://dl.afrino.co"
}, },
@@ -62,7 +64,7 @@ stringData:
"Sms": { "Sms": {
"Enabled": true, "Enabled": true,
"Provider": "Kavenegar", "Provider": "Kavenegar",
"KavenegarApiKey": "497263626F32626A48685A6137524C4F78575A766E4C74694A556B79317648424964655030682B554545413D", "KavenegarApiKey": "43676A4E786A6C50452F2F6252507939346A5145764B33566B456454374E657A614468706658656D6534593D",
"Sender": "1000001110100" "Sender": "1000001110100"
}, },
"DayaPayment": { "DayaPayment": {
+4
View File
@@ -101,6 +101,8 @@ spec:
--- ---
# CMS Ingress # CMS Ingress
# If clients report TLS PartialChain, the tls secret (cms-tls) tls.crt must include the full chain
# (leaf + intermediates). cert-manager ACME usually provides this; manual certs need fullchain.pem in tls.crt.
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
@@ -110,6 +112,8 @@ metadata:
kubernetes.io/ingress.class: "nginx" kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt-prod" cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec: spec:
tls: tls:
- hosts: - hosts:
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.FileManager; using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog; using CMSMicroservice.Domain.Entities.Blog;
@@ -32,12 +33,13 @@ public class CreateBlogPostCommandHandler : IRequestHandler<CreateBlogPostComman
if (string.IsNullOrEmpty(currentUserId) || !long.TryParse(currentUserId, out var authorUserId)) if (string.IsNullOrEmpty(currentUserId) || !long.TryParse(currentUserId, out var authorUserId))
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است"); throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
var slug = BlogSlugHelper.ResolveForCreate(request.Slug, request.Title.Trim());
slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(_context, slug, cancellationToken: cancellationToken);
var post = new BlogPost var post = new BlogPost
{ {
Title = request.Title.Trim(), Title = request.Title.Trim(),
Slug = string.IsNullOrWhiteSpace(request.Slug) Slug = slug,
? $"post-{Guid.NewGuid():N}".Substring(0, 20)
: request.Slug.Trim().ToLower(),
Summary = request.Summary?.Trim(), Summary = request.Summary?.Trim(),
HtmlContent = request.HtmlContent, HtmlContent = request.HtmlContent,
FeaturedImagePath = request.FeaturedImagePath, FeaturedImagePath = request.FeaturedImagePath,
@@ -11,9 +11,9 @@ public class CreateBlogPostCommandValidator : AbstractValidator<CreateBlogPostCo
.MaximumLength(200).WithMessage("عنوان نمی‌تواند بیشتر از 200 کاراکتر باشد"); .MaximumLength(200).WithMessage("عنوان نمی‌تواند بیشتر از 200 کاراکتر باشد");
RuleFor(x => x.Slug) RuleFor(x => x.Slug)
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
.MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد") .MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد")
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"); .Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد")
.When(x => !string.IsNullOrWhiteSpace(x.Slug));
RuleFor(x => x.Summary) RuleFor(x => x.Summary)
.MaximumLength(500).WithMessage("خلاصه نمی‌تواند بیشتر از 500 کاراکتر باشد") .MaximumLength(500).WithMessage("خلاصه نمی‌تواند بیشتر از 500 کاراکتر باشد")
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.FileManager; using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog; using CMSMicroservice.Domain.Entities.Blog;
@@ -29,9 +30,21 @@ public class UpdateBlogPostCommandHandler : IRequestHandler<UpdateBlogPostComman
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد"); ?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
post.Title = request.Title.Trim(); post.Title = request.Title.Trim();
// حفظ slug قبلی اگر مقدار جدید خالی باشد
if (!string.IsNullOrWhiteSpace(request.Slug)) if (!string.IsNullOrWhiteSpace(request.Slug))
post.Slug = request.Slug.Trim().ToLower(); {
post.Slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(
_context,
BlogSlugHelper.NormalizeOptional(request.Slug)!,
post.Id,
cancellationToken);
}
else if (string.IsNullOrWhiteSpace(post.Slug))
{
var slug = BlogSlugHelper.ResolveForCreate(null, post.Title);
post.Slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(
_context, slug, post.Id, cancellationToken);
}
post.Summary = request.Summary?.Trim(); post.Summary = request.Summary?.Trim();
post.HtmlContent = request.HtmlContent; post.HtmlContent = request.HtmlContent;
post.FeaturedImagePath = request.FeaturedImagePath; post.FeaturedImagePath = request.FeaturedImagePath;
@@ -13,9 +13,9 @@ public class UpdateBlogPostCommandValidator : AbstractValidator<UpdateBlogPostCo
.MaximumLength(200).WithMessage("عنوان نمی‌تواند بیشتر از 200 کاراکتر باشد"); .MaximumLength(200).WithMessage("عنوان نمی‌تواند بیشتر از 200 کاراکتر باشد");
RuleFor(x => x.Slug) RuleFor(x => x.Slug)
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
.MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد") .MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد")
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"); .Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد")
.When(x => !string.IsNullOrWhiteSpace(x.Slug));
RuleFor(x => x.HtmlContent) RuleFor(x => x.HtmlContent)
.NotEmpty().WithMessage("محتوای مقاله الزامی است"); .NotEmpty().WithMessage("محتوای مقاله الزامی است");
@@ -135,15 +135,65 @@ public class AcceptClubMembershipContractCommandHandler
}; };
await _context.UserContracts.AddAsync(userContract, cancellationToken); await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. بارگذاری پکیج از آخرین خرید کاربر یا پکیج پایه // 6. بارگذاری پکیج موثر برای امضای قرارداد
// اولویت:
// 1) پکیج Cycle جاری (مسیر صحیح برای پرداخت دستی)
// 2) آخرین خرید پکیج
// 3) پکیج پایه
var currentCyclePackageId = await _context.ClubMembershipCycles
.Where(c => c.UserId == userId && c.IsCurrentCycle)
.OrderByDescending(c => c.CycleNumber)
.Select(c => (long?)c.PackageId)
.FirstOrDefaultAsync(cancellationToken);
var latestPurchase = await _context.UserPackagePurchases var latestPurchase = await _context.UserPackagePurchases
.Where(p => p.UserId == userId) .Where(p => p.UserId == userId)
.OrderByDescending(p => p.PurchasedAt) .OrderByDescending(p => p.PurchasedAt)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
var package = latestPurchase != null Package? package = null;
? await _context.Packages.FirstOrDefaultAsync(p => p.Id == latestPurchase.PackageId && !p.IsDeleted, cancellationToken)
: await _context.Packages.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken); if (currentCyclePackageId.HasValue && currentCyclePackageId.Value > 0)
{
package = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == currentCyclePackageId.Value && !p.IsDeleted, cancellationToken);
if (package != null)
{
_logger.LogInformation(
"AcceptContract package source: CurrentCycle (UserId={UserId}, PackageId={PackageId})",
userId,
package.Id);
}
}
if (package == null && latestPurchase != null)
{
package = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == latestPurchase.PackageId && !p.IsDeleted, cancellationToken);
if (package != null)
{
_logger.LogInformation(
"AcceptContract package source: LatestPurchase (UserId={UserId}, PackageId={PackageId})",
userId,
package.Id);
}
}
if (package == null)
{
package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken);
if (package != null)
{
_logger.LogInformation(
"AcceptContract package source: BasePackage (UserId={UserId}, PackageId={PackageId})",
userId,
package.Id);
}
}
if (package == null) if (package == null)
throw new NotFoundException("پکیج یافت نشد"); throw new NotFoundException("پکیج یافت نشد");
@@ -221,7 +271,54 @@ public class AcceptClubMembershipContractCommandHandler
_context.ClubMembershipHistories.Add(history); _context.ClubMembershipHistories.Add(history);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// 9. اضافه کردن مبلغ به Pool هفته جاری // 8.5. آپدیت PackagePurchasedAt سایکل جاری به زمان امضای قرارداد
// دلیل: Pool در همین لحظه شارژ می‌شود — Validation باید به همین هفته اشاره کند
// سناریوی cross-week: کاربر هفته ۱۸ پکیج خریده، هفته ۱۹ قرارداد امضا کرده
// بدون این Fix: Cycle.PackagePurchasedAt = هفته ۱۸ ولی Pool هفته ۱۹ → اختلاف
// با این Fix: Cycle.PackagePurchasedAt = هفته ۱۹ = Pool هفته ۱۹ → درست
var currentCycle = await _context.ClubMembershipCycles
.FirstOrDefaultAsync(c => c.UserId == userId && c.IsCurrentCycle, cancellationToken);
if (currentCycle != null)
{
currentCycle.PackagePurchasedAt = activationDate;
_context.ClubMembershipCycles.Update(currentCycle);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Updated Cycle #{CycleNumber} PackagePurchasedAt to contract-signing time for UserId {UserId}",
currentCycle.CycleNumber,
userId
);
}
else
{
// در صورتی که Cycle وجود ندارد (legacy یا مسیر مستقیم AcceptContract)
// Cycle را همین‌جا بساز تا Validation درست کار کند
_logger.LogWarning(
"No IsCurrentCycle found for UserId {UserId} at AcceptContract — creating Cycle now",
userId
);
var maxCycleNumber = await _context.ClubMembershipCycles
.Where(c => c.ClubMembershipId == clubMembership.Id)
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
var newCycle = new ClubMembershipCycle
{
UserId = userId,
ClubMembershipId = clubMembership.Id,
CycleNumber = maxCycleNumber + 1,
PackagePurchasedAt = activationDate,
PurchaseMethod = user.PackagePurchaseMethod,
PackageAmount = package.Price,
PackageId = package.Id,
IsCurrentCycle = true
};
_context.ClubMembershipCycles.Add(newCycle);
await _context.SaveChangesAsync(cancellationToken);
}
var currentWeekDefinitionId = GetCurrentWeekDefinitionId(); var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken); .FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
@@ -17,4 +17,10 @@ public record ActivateClubMembershipCommand : IRequest<bool>
/// فعال‌سازی اجباری توسط ادمین — بدون بررسی سفارش و کیف‌پول /// فعال‌سازی اجباری توسط ادمین — بدون بررسی سفارش و کیف‌پول
/// </summary> /// </summary>
public bool ForceActivation { get; init; } public bool ForceActivation { get; init; }
/// <summary>
/// شناسه پکیج — فقط در ForceActivation استفاده می‌شود.
/// اگر 0 باشد، پکیج پایه (IsBasePackage) استفاده می‌شود.
/// </summary>
public long PackageId { get; init; }
} }
@@ -1,4 +1,5 @@
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Helpers;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models; using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
@@ -162,19 +163,28 @@ 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);
} }
// بارگذاری پکیج پایه برای فعال‌سازی اجباری // بارگذاری پکیج — اگر PackageId مشخص شده همان را لود کن، وگرنه پکیج پایه
if (request.PackageId > 0)
{
package = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == request.PackageId && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException($"پکیج {request.PackageId} یافت نشد");
}
else
{
package = await _context.Packages package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken) .FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException("پکیج پایه یافت نشد"); ?? throw new NotFoundException("پکیج پایه یافت نشد");
} }
}
// 6. بررسی عضویت فعلی // 6. بررسی عضویت فعلی
var existingMembership = await _context.ClubMemberships var existingMembership = await _context.ClubMemberships
@@ -226,10 +236,12 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
var hasCurrentCycle = await _context.ClubMembershipCycles var hasCurrentCycle = await _context.ClubMembershipCycles
.AnyAsync(c => c.UserId == user.Id && c.IsCurrentCycle, cancellationToken); .AnyAsync(c => c.UserId == user.Id && c.IsCurrentCycle, cancellationToken);
if (existingMembership.IsActive && hasCurrentCycle) // اگر چرخه فعال دارد و درخواست Force نیست — احتمال اجرای تکراری همان خرید
// در حالت ForceActivation (مثل پرداخت دستی)، باید چرخه جدید ساخته شود تا پکیج جدید ثبت شود.
if (hasCurrentCycle && !request.ForceActivation)
{ {
_logger.LogInformation( _logger.LogInformation(
"User {UserId} is already an active club member with a running cycle", "User {UserId} already has a running cycle — skipping duplicate activation",
user.Id user.Id
); );
return true; return true;
@@ -261,6 +273,7 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
foreach (var prevCycle in previousCycles) foreach (var prevCycle in previousCycles)
{ {
prevCycle.IsCurrentCycle = false; prevCycle.IsCurrentCycle = false;
ClubMembershipCycleHistoryWriter.WriteCycleClosed(_context, prevCycle, reason: "شروع دوره جدید");
} }
var maxCycleNumber = await _context.ClubMembershipCycles var maxCycleNumber = await _context.ClubMembershipCycles
@@ -282,6 +295,9 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
_context.ClubMembershipCycles.Add(newCycle); _context.ClubMembershipCycles.Add(newCycle);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
ClubMembershipCycleHistoryWriter.WriteCycleStarted(_context, newCycle, reason: "فعال‌سازی عضویت باشگاه");
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation( _logger.LogInformation(
"Created ClubMembershipCycle #{CycleNumber} for UserId {UserId}, MembershipId {MembershipId}", "Created ClubMembershipCycle #{CycleNumber} for UserId {UserId}, MembershipId {MembershipId}",
newCycle.CycleNumber, newCycle.CycleNumber,
@@ -307,6 +323,11 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری // ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
// عضو جدید (isNewMembership=true): Pool توسط AcceptClubMembershipContract شارژ می‌شود
// چون کاربر قبل از امضای قرارداد IsActive=false است و قرارداد فقط یک‌بار امضا می‌شود.
// خرید مجدد (isNewMembership=false): قرارداد مجدد امضا نمی‌شود — Pool همین‌جا شارژ می‌شود.
if (!isNewMembership)
{
var currentWeekDefinitionId = GetCurrentWeekDefinitionId(); var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken); .FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
@@ -319,8 +340,8 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
WeekDefinitionId = currentWeekDefinitionId, WeekDefinitionId = currentWeekDefinitionId,
PackageId = package.Id, PackageId = package.Id,
TotalPoolAmount = activationFeeValue, TotalPoolAmount = activationFeeValue,
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه TotalBalances = 0,
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه ValuePerBalance = 0,
IsCalculated = false, IsCalculated = false,
CalculatedAt = null CalculatedAt = null
}; };
@@ -330,12 +351,11 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
_logger.LogInformation( _logger.LogInformation(
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}", "Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId, currentWeekDefinitionId,
giftValue activationFeeValue
); );
} }
else else
{ {
// اضافه کردن به Pool موجود
weeklyPool.TotalPoolAmount += activationFeeValue; weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool); _context.WeeklyCommissionPools.Update(weeklyPool);
@@ -348,6 +368,14 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
} }
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
}
else
{
_logger.LogInformation(
"New member — Pool charge deferred to AcceptClubMembershipContract for UserId {UserId}",
user.Id
);
}
// 9. اعمال ویژگی‌های پکیج — DIFF/تفاضل (Q20) // 9. اعمال ویژگی‌های پکیج — DIFF/تفاضل (Q20)
await ApplyFeatureDiffAsync(user.Id, entity.Id, package.Id, isNewMembership, activationDate, cancellationToken); await ApplyFeatureDiffAsync(user.Id, entity.Id, package.Id, isNewMembership, activationDate, cancellationToken);
@@ -13,6 +13,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
{ {
var query = _context.ClubMemberships var query = _context.ClubMemberships
.Include(x => x.User) .Include(x => x.User)
.Include(x => x.LastPackage)
.ApplyOrder(sortBy: request.SortBy) .ApplyOrder(sortBy: request.SortBy)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
@@ -35,8 +36,10 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
Id = x.Id, Id = x.Id,
UserId = x.UserId, UserId = x.UserId,
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty, UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
PackageId = x.LastPackageId ?? 0,
PackageName = x.LastPackage != null ? x.LastPackage.Title : string.Empty,
IsActive = x.IsActive, IsActive = x.IsActive,
ActivatedAt = x.ActivatedAt, ActivatedAt = x.LastActivationDate ?? x.ActivatedAt,
InitialContribution = x.InitialContribution, InitialContribution = x.InitialContribution,
TotalEarned = x.TotalEarned, TotalEarned = x.TotalEarned,
Created = x.Created, Created = x.Created,
@@ -11,6 +11,8 @@ public class GetAllClubMembershipsResponseModel
public long Id { get; set; } public long Id { get; set; }
public long UserId { get; set; } public long UserId { get; set; }
public string UserName { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty;
public long PackageId { get; set; }
public string PackageName { get; set; } = string.Empty;
public bool IsActive { get; set; } public bool IsActive { get; set; }
public DateTime? ActivatedAt { get; set; } public DateTime? ActivatedAt { get; set; }
public long InitialContribution { get; set; } public long InitialContribution { get; set; }
@@ -1,27 +1,17 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory; namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
/// <summary>
/// Query برای دریافت تاریخچه تغییرات عضویت باشگاه
/// </summary>
public record GetClubMembershipHistoryQuery : IRequest<GetClubMembershipHistoryResponseDto> public record GetClubMembershipHistoryQuery : IRequest<GetClubMembershipHistoryResponseDto>
{ {
/// <summary>
/// شناسه عضویت (اختیاری)
/// </summary>
public long? MembershipId { get; init; }
/// <summary>
/// شناسه کاربر (اختیاری)
/// </summary>
public long? UserId { get; init; }
/// <summary>
/// موقعیت صفحه‌بندی
/// </summary>
public PaginationState? PaginationState { get; init; } public PaginationState? PaginationState { get; init; }
/// <summary>
/// مرتب‌سازی بر اساس
/// </summary>
public string? SortBy { get; init; } public string? SortBy { get; init; }
public GetClubMembershipHistoryFilter? Filter { get; init; }
}
public class GetClubMembershipHistoryFilter
{
public long? UserId { get; set; }
public long? ClubMembershipId { get; set; }
public ClubMembershipAction? Action { get; set; }
public DateTime? CreatedFrom { get; set; }
public DateTime? CreatedTo { get; set; }
} }
@@ -12,20 +12,30 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
public async Task<GetClubMembershipHistoryResponseDto> Handle(GetClubMembershipHistoryQuery request, CancellationToken cancellationToken) public async Task<GetClubMembershipHistoryResponseDto> Handle(GetClubMembershipHistoryQuery request, CancellationToken cancellationToken)
{ {
var query = _context.ClubMembershipHistories var query = _context.ClubMembershipHistories
.Include(x => x.ClubMembership)
.ThenInclude(m => m!.User)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .AsQueryable();
if (request.MembershipId.HasValue) if (request.Filter != null)
{ {
query = query.Where(x => x.ClubMembershipId == request.MembershipId.Value); if (request.Filter.ClubMembershipId.HasValue)
query = query.Where(x => x.ClubMembershipId == request.Filter.ClubMembershipId.Value);
if (request.Filter.UserId.HasValue)
query = query.Where(x => x.UserId == request.Filter.UserId.Value);
if (request.Filter.Action.HasValue)
query = query.Where(x => x.Action == request.Filter.Action.Value);
if (request.Filter.CreatedFrom.HasValue)
query = query.Where(x => x.Created >= request.Filter.CreatedFrom.Value);
if (request.Filter.CreatedTo.HasValue)
query = query.Where(x => x.Created <= request.Filter.CreatedTo.Value);
} }
if (request.UserId.HasValue) query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
{
query = query.Where(x => x.UserId == request.UserId.Value);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created"); // پیش‌فرض: جدیدترین اول
var meta = await query.GetMetaData(request.PaginationState, cancellationToken); var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -36,6 +46,9 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
Id = x.Id, Id = x.Id,
ClubMembershipId = x.ClubMembershipId, ClubMembershipId = x.ClubMembershipId,
UserId = x.UserId, UserId = x.UserId,
UserName = x.ClubMembership != null && x.ClubMembership.User != null
? $"{x.ClubMembership.User.FirstName} {x.ClubMembership.User.LastName}"
: string.Empty,
OldIsActive = x.OldIsActive, OldIsActive = x.OldIsActive,
NewIsActive = x.NewIsActive, NewIsActive = x.NewIsActive,
OldInitialContribution = x.OldInitialContribution, OldInitialContribution = x.OldInitialContribution,
@@ -4,31 +4,6 @@ public class GetClubMembershipHistoryQueryValidator : AbstractValidator<GetClubM
{ {
public GetClubMembershipHistoryQueryValidator() public GetClubMembershipHistoryQueryValidator()
{ {
RuleFor(x => x.MembershipId) // Admin report: filters are all optional
.GreaterThan(0)
.WithMessage("شناسه عضویت معتبر نیست")
.When(x => x.MembershipId.HasValue);
RuleFor(x => x.UserId)
.GreaterThan(0)
.WithMessage("شناسه کاربر معتبر نیست")
.When(x => x.UserId.HasValue);
RuleFor(x => x)
.Must(x => x.MembershipId.HasValue || x.UserId.HasValue)
.WithMessage("حداقل یکی از MembershipId یا UserId باید مقداردهی شود");
} }
public Func<object, string, Task<IEnumerable<string>>> ValidateValue => async (model, propertyName) =>
{
var result = await ValidateAsync(
ValidationContext<GetClubMembershipHistoryQuery>.CreateWithOptions(
(GetClubMembershipHistoryQuery)model,
x => x.IncludeProperties(propertyName)));
if (result.IsValid)
return Array.Empty<string>();
return result.Errors.Select(e => e.ErrorMessage);
};
} }
@@ -11,6 +11,7 @@ public class GetClubMembershipHistoryResponseModel
public long Id { get; set; } public long Id { get; set; }
public long ClubMembershipId { get; set; } public long ClubMembershipId { get; set; }
public long UserId { get; set; } public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public bool OldIsActive { get; set; } public bool OldIsActive { get; set; }
public bool NewIsActive { get; set; } public bool NewIsActive { get; set; }
public long? OldInitialContribution { get; set; } public long? OldInitialContribution { get; set; }
@@ -0,0 +1,46 @@
using CMSMicroservice.Domain.Entities.Club;
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
public static class ClubMembershipCycleFilterExtensions
{
public static IQueryable<ClubMembershipCycle> ApplyClubMembershipCycleFilter(
this IQueryable<ClubMembershipCycle> query,
GetAllClubMembershipCycleByFilterFilter? filter)
{
if (filter == null) return query;
if (filter.UserId.HasValue)
query = query.Where(x => x.UserId == filter.UserId.Value);
if (filter.IsCurrentCycle.HasValue)
query = query.Where(x => x.IsCurrentCycle == filter.IsCurrentCycle.Value);
if (filter.PackageId.HasValue)
query = query.Where(x => x.PackageId == filter.PackageId.Value);
if (filter.CycleNumber.HasValue)
query = query.Where(x => x.CycleNumber == filter.CycleNumber.Value);
if (filter.PurchaseMethod.HasValue)
query = query.Where(x => x.PurchaseMethod == filter.PurchaseMethod.Value);
if (filter.MagicState.HasValue)
{
query = filter.MagicState.Value switch
{
1 => query.Where(x => x.MagicStartedAt != null && x.MagicCompletedAt == null),
2 => query.Where(x => x.MagicCompletedAt != null),
_ => query.Where(x => x.MagicStartedAt == null && x.MagicCompletedAt == null)
};
}
if (filter.PackagePurchasedFrom.HasValue)
query = query.Where(x => x.PackagePurchasedAt >= filter.PackagePurchasedFrom.Value);
if (filter.PackagePurchasedTo.HasValue)
query = query.Where(x => x.PackagePurchasedAt <= filter.PackagePurchasedTo.Value);
return query;
}
}
@@ -0,0 +1,47 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
public record GetAllClubMembershipCycleByFilterQuery : IRequest<GetAllClubMembershipCycleByFilterResponseDto>
{
public PaginationState? PaginationState { get; init; }
public string? SortBy { get; init; }
public GetAllClubMembershipCycleByFilterFilter? Filter { get; init; }
}
public class GetAllClubMembershipCycleByFilterFilter
{
public long? UserId { get; set; }
public bool? IsCurrentCycle { get; set; }
public long? PackageId { get; set; }
public int? CycleNumber { get; set; }
public PackagePurchaseMethod? PurchaseMethod { get; set; }
/// <summary>0=none, 1=active magic, 2=completed magic</summary>
public int? MagicState { get; set; }
public DateTime? PackagePurchasedFrom { get; set; }
public DateTime? PackagePurchasedTo { get; set; }
}
public class GetAllClubMembershipCycleByFilterResponseDto
{
public MetaData MetaData { get; set; }
public List<GetAllClubMembershipCycleByFilterResponseModel>? Models { get; set; }
}
public class GetAllClubMembershipCycleByFilterResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public long ClubMembershipId { get; set; }
public int CycleNumber { get; set; }
public long PackageId { get; set; }
public string PackageName { get; set; } = string.Empty;
public long PackageAmount { get; set; }
public PackagePurchaseMethod PurchaseMethod { get; set; }
public DateTime PackagePurchasedAt { get; set; }
public DateTime? MagicStartedAt { get; set; }
public DateTime? MagicCompletedAt { get; set; }
public bool IsCurrentCycle { get; set; }
public DateTimeOffset Created { get; set; }
}
@@ -0,0 +1,49 @@
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
public class GetAllClubMembershipCycleByFilterQueryHandler
: IRequestHandler<GetAllClubMembershipCycleByFilterQuery, GetAllClubMembershipCycleByFilterResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllClubMembershipCycleByFilterQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllClubMembershipCycleByFilterResponseDto> Handle(
GetAllClubMembershipCycleByFilterQuery request,
CancellationToken cancellationToken)
{
var query = _context.ClubMembershipCycles
.Include(x => x.User)
.Include(x => x.Package)
.AsNoTracking()
.ApplyClubMembershipCycleFilter(request.Filter)
.ApplyOrder(sortBy: request.SortBy ?? "-PackagePurchasedAt");
return new GetAllClubMembershipCycleByFilterResponseDto
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetAllClubMembershipCycleByFilterResponseModel
{
Id = x.Id,
UserId = x.UserId,
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
ClubMembershipId = x.ClubMembershipId,
CycleNumber = x.CycleNumber,
PackageId = x.PackageId,
PackageName = x.Package != null ? x.Package.Title : string.Empty,
PackageAmount = x.PackageAmount,
PurchaseMethod = x.PurchaseMethod,
PackagePurchasedAt = x.PackagePurchasedAt,
MagicStartedAt = x.MagicStartedAt,
MagicCompletedAt = x.MagicCompletedAt,
IsCurrentCycle = x.IsCurrentCycle,
Created = x.Created
})
.ToListAsync(cancellationToken)
};
}
}
@@ -0,0 +1,13 @@
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetClubMembershipCycleSummary;
public record GetClubMembershipCycleSummaryQuery : IRequest<GetClubMembershipCycleSummaryResponseDto>
{
public GetAllClubMembershipCycleByFilter.GetAllClubMembershipCycleByFilterFilter? Filter { get; init; }
}
public class GetClubMembershipCycleSummaryResponseDto
{
public long TotalCount { get; set; }
public long CurrentCycleCount { get; set; }
public long MagicActiveCount { get; set; }
}
@@ -0,0 +1,36 @@
using CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetAllClubMembershipCycleByFilter;
namespace CMSMicroservice.Application.ClubMembershipCycleCQ.Queries.GetClubMembershipCycleSummary;
public class GetClubMembershipCycleSummaryQueryHandler
: IRequestHandler<GetClubMembershipCycleSummaryQuery, GetClubMembershipCycleSummaryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetClubMembershipCycleSummaryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetClubMembershipCycleSummaryResponseDto> Handle(
GetClubMembershipCycleSummaryQuery request,
CancellationToken cancellationToken)
{
var query = _context.ClubMembershipCycles
.AsNoTracking()
.ApplyClubMembershipCycleFilter(request.Filter);
var totalCount = await query.LongCountAsync(cancellationToken);
var currentCycleCount = await query.LongCountAsync(x => x.IsCurrentCycle, cancellationToken);
var magicActiveCount = await query.LongCountAsync(
x => x.MagicStartedAt != null && x.MagicCompletedAt == null,
cancellationToken);
return new GetClubMembershipCycleSummaryResponseDto
{
TotalCount = totalCount,
CurrentCycleCount = currentCycleCount,
MagicActiveCount = magicActiveCount
};
}
}
@@ -0,0 +1,33 @@
using CMSMicroservice.Domain.Entities.History;
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
public static class ClubMembershipCycleHistoryFilterExtensions
{
public static IQueryable<ClubMembershipCycleHistory> ApplyClubMembershipCycleHistoryFilter(
this IQueryable<ClubMembershipCycleHistory> query,
GetAllClubMembershipCycleHistoryByFilterFilter? filter)
{
if (filter == null) return query;
if (filter.UserId.HasValue)
query = query.Where(x => x.UserId == filter.UserId.Value);
if (filter.ClubMembershipCycleId.HasValue)
query = query.Where(x => x.ClubMembershipCycleId == filter.ClubMembershipCycleId.Value);
if (filter.CycleNumber.HasValue)
query = query.Where(x => x.CycleNumber == filter.CycleNumber.Value);
if (filter.Action.HasValue)
query = query.Where(x => x.Action == filter.Action.Value);
if (filter.CreatedFrom.HasValue)
query = query.Where(x => x.Created >= filter.CreatedFrom.Value);
if (filter.CreatedTo.HasValue)
query = query.Where(x => x.Created <= filter.CreatedTo.Value);
return query;
}
}
@@ -0,0 +1,45 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
public record GetAllClubMembershipCycleHistoryByFilterQuery : IRequest<GetAllClubMembershipCycleHistoryByFilterResponseDto>
{
public PaginationState? PaginationState { get; init; }
public string? SortBy { get; init; }
public GetAllClubMembershipCycleHistoryByFilterFilter? Filter { get; init; }
}
public class GetAllClubMembershipCycleHistoryByFilterFilter
{
public long? UserId { get; set; }
public long? ClubMembershipCycleId { get; set; }
public int? CycleNumber { get; set; }
public ClubMembershipCycleAction? Action { get; set; }
public DateTime? CreatedFrom { get; set; }
public DateTime? CreatedTo { get; set; }
}
public class GetAllClubMembershipCycleHistoryByFilterResponseDto
{
public MetaData MetaData { get; set; }
public List<GetAllClubMembershipCycleHistoryByFilterResponseModel>? Models { get; set; }
}
public class GetAllClubMembershipCycleHistoryByFilterResponseModel
{
public long Id { get; set; }
public long ClubMembershipCycleId { get; set; }
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public int CycleNumber { get; set; }
public bool OldIsCurrentCycle { get; set; }
public bool NewIsCurrentCycle { get; set; }
public DateTime? OldMagicStartedAt { get; set; }
public DateTime? NewMagicStartedAt { get; set; }
public DateTime? OldMagicCompletedAt { get; set; }
public DateTime? NewMagicCompletedAt { get; set; }
public ClubMembershipCycleAction Action { get; set; }
public string? PerformedBy { get; set; }
public string? Reason { get; set; }
public DateTimeOffset Created { get; set; }
}
@@ -0,0 +1,52 @@
namespace CMSMicroservice.Application.ClubMembershipCycleHistoryCQ.Queries.GetAllClubMembershipCycleHistoryByFilter;
public class GetAllClubMembershipCycleHistoryByFilterQueryHandler
: IRequestHandler<GetAllClubMembershipCycleHistoryByFilterQuery, GetAllClubMembershipCycleHistoryByFilterResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllClubMembershipCycleHistoryByFilterQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllClubMembershipCycleHistoryByFilterResponseDto> Handle(
GetAllClubMembershipCycleHistoryByFilterQuery request,
CancellationToken cancellationToken)
{
var query = _context.ClubMembershipCycleHistories
.Include(x => x.ClubMembershipCycle)
.ThenInclude(c => c!.User)
.AsNoTracking()
.ApplyClubMembershipCycleHistoryFilter(request.Filter)
.ApplyOrder(sortBy: request.SortBy ?? "-Created");
return new GetAllClubMembershipCycleHistoryByFilterResponseDto
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetAllClubMembershipCycleHistoryByFilterResponseModel
{
Id = x.Id,
ClubMembershipCycleId = x.ClubMembershipCycleId,
UserId = x.UserId,
UserName = x.ClubMembershipCycle != null && x.ClubMembershipCycle.User != null
? $"{x.ClubMembershipCycle.User.FirstName} {x.ClubMembershipCycle.User.LastName}"
: string.Empty,
CycleNumber = x.CycleNumber,
OldIsCurrentCycle = x.OldIsCurrentCycle,
NewIsCurrentCycle = x.NewIsCurrentCycle,
OldMagicStartedAt = x.OldMagicStartedAt,
NewMagicStartedAt = x.NewMagicStartedAt,
OldMagicCompletedAt = x.OldMagicCompletedAt,
NewMagicCompletedAt = x.NewMagicCompletedAt,
Action = x.Action,
PerformedBy = x.PerformedBy,
Reason = x.Reason,
Created = x.Created
})
.ToListAsync(cancellationToken)
};
}
}
@@ -0,0 +1,12 @@
namespace CMSMicroservice.Application.CommissionCQ.Commands.BulkCreditPayouts;
public class BulkCreditPayoutsCommand : IRequest<BulkCreditPayoutsResult>
{
public long WeekDefinitionId { get; set; }
}
public class BulkCreditPayoutsResult
{
public int CreditedCount { get; set; }
public long TotalCredited { get; set; }
}
@@ -0,0 +1,82 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Commission;
using CMSMicroservice.Domain.Entities.History;
using CMSMicroservice.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.CommissionCQ.Commands.BulkCreditPayouts;
public class BulkCreditPayoutsCommandHandler : IRequestHandler<BulkCreditPayoutsCommand, BulkCreditPayoutsResult>
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
private readonly ILogger<BulkCreditPayoutsCommandHandler> _logger;
public BulkCreditPayoutsCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser,
ILogger<BulkCreditPayoutsCommandHandler> logger)
{
_context = context;
_currentUser = currentUser;
_logger = logger;
}
public async Task<BulkCreditPayoutsResult> Handle(BulkCreditPayoutsCommand request, CancellationToken cancellationToken)
{
var pendingPayouts = await _context.UserCommissionPayouts
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId
&& p.Status == CommissionPayoutStatus.Pending
&& !p.IsDeleted)
.ToListAsync(cancellationToken);
if (!pendingPayouts.Any())
{
return new BulkCreditPayoutsResult { CreditedCount = 0, TotalCredited = 0 };
}
var performedBy = _currentUser.GetPerformedBy() ?? "System";
var now = DateTime.UtcNow;
var payoutHistories = new List<CommissionPayoutHistory>();
var creditedCount = 0;
long totalCredited = 0;
foreach (var payout in pendingPayouts)
{
payout.Status = CommissionPayoutStatus.Paid;
payout.PaidAt = now;
payout.LastModified = now;
payoutHistories.Add(new CommissionPayoutHistory
{
UserCommissionPayoutId = payout.Id,
UserId = payout.UserId,
WeekDefinitionId = payout.WeekDefinitionId,
AmountBefore = payout.TotalAmount,
AmountAfter = payout.TotalAmount,
OldStatus = CommissionPayoutStatus.Pending,
NewStatus = CommissionPayoutStatus.Paid,
Action = CommissionPayoutAction.Paid,
PerformedBy = performedBy,
Reason = $"تایید پرداخت دسته‌جمعی کمیسیون هفته {payout.WeekDefinitionId} توسط {performedBy}"
});
creditedCount++;
totalCredited += payout.TotalAmount;
}
await _context.CommissionPayoutHistories.AddRangeAsync(payoutHistories, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"BulkCreditPayouts completed: Week={WeekId}, Credited={Count}, TotalAmount={Total}, By={By}",
request.WeekDefinitionId, creditedCount, totalCredited, performedBy);
return new BulkCreditPayoutsResult
{
CreditedCount = creditedCount,
TotalCredited = totalCredited
};
}
}
@@ -266,12 +266,16 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var count = 0; var count = 0;
// اگر فرزند در این هفته پکیج خریده (Cycle جدید ساخته شده)، 1 امتیاز // ⭐ بر اساس بازه تاریخی هفته، نه IsCurrentCycle
// ⚠️ از ClubMembershipCycle.PackagePurchasedAt استفاده میکنیم (نه ActivatedAt که overwrite میشد) // IsCurrentCycle فقط آخرین خرید را نشان می‌دهد — برای تاریخچه‌ای (هفته‌های گذشته)
var currentCycle = await _context.ClubMembershipCycles // باید از PackagePurchasedAt استفاده کرد که تاریخ دقیق خرید را نگه می‌دارد
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsCurrentCycle, cancellationToken); var hadCycleInWeek = await _context.ClubMembershipCycles
.AnyAsync(x => x.UserId == child.Id
&& x.PackagePurchasedAt >= startDate
&& x.PackagePurchasedAt <= endDate,
cancellationToken);
if (currentCycle?.PackagePurchasedAt >= startDate && currentCycle?.PackagePurchasedAt <= endDate) if (hadCycleInWeek)
{ {
count = 1; count = 1;
} }
@@ -10,6 +10,11 @@ public record CalculateWeeklyCommissionPoolCommand : IRequest<long>
/// </summary> /// </summary>
public long WeekDefinitionId { get; init; } public long WeekDefinitionId { get; init; }
/// <summary>
/// شناسه پکیج — اگر مقدار داشته باشد فقط همین پکیج محاسبه می‌شود.
/// </summary>
public long? PackageId { get; init; }
/// <summary> /// <summary>
/// آیا محاسبه مجدد انجام شود؟ /// آیا محاسبه مجدد انجام شود؟
/// </summary> /// </summary>
@@ -23,8 +23,17 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
} }
// بررسی وجود استخر // بررسی وجود استخر
var existingPool = await _context.WeeklyCommissionPools var existingPoolQuery = _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekDefinitionId == request.WeekDefinitionId, cancellationToken); .Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
if (request.PackageId.HasValue)
{
existingPoolQuery = existingPoolQuery.Where(x => x.PackageId == request.PackageId.Value);
}
var existingPool = await existingPoolQuery
.OrderBy(x => x.Id)
.FirstOrDefaultAsync(cancellationToken);
if (existingPool == null) if (existingPool == null)
{ {
@@ -40,9 +49,15 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
} }
// بررسی وجود تعادل‌های هفتگی // بررسی وجود تعادل‌های هفتگی
var weeklyBalances = await _context.NetworkWeeklyBalances var weeklyBalancesQuery = _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId) .Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
.ToListAsync(cancellationToken);
if (request.PackageId.HasValue)
{
weeklyBalancesQuery = weeklyBalancesQuery.Where(x => x.PackageId == request.PackageId.Value);
}
var weeklyBalances = await weeklyBalancesQuery.ToListAsync(cancellationToken);
if (!weeklyBalances.Any()) if (!weeklyBalances.Any())
{ {
@@ -76,9 +91,15 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
// حذف پرداخت‌های قبلی در صورت ForceRecalculate // حذف پرداخت‌های قبلی در صورت ForceRecalculate
if (request.ForceRecalculate) if (request.ForceRecalculate)
{ {
var oldPayouts = await _context.UserCommissionPayouts var oldPayoutsQuery = _context.UserCommissionPayouts
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId) .Where(p => p.WeekDefinitionId == request.WeekDefinitionId);
.ToListAsync(cancellationToken);
if (request.PackageId.HasValue)
{
oldPayoutsQuery = oldPayoutsQuery.Where(p => p.PackageId == request.PackageId.Value);
}
var oldPayouts = await oldPayoutsQuery.ToListAsync(cancellationToken);
if (oldPayouts.Any()) if (oldPayouts.Any())
{ {
@@ -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);
@@ -21,4 +21,9 @@ public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculatio
/// Skip pool calculation and payout processing (Step 2) /// Skip pool calculation and payout processing (Step 2)
/// </summary> /// </summary>
public bool SkipPayouts { get; init; } public bool SkipPayouts { get; init; }
/// <summary>
/// شناسه پکیج — اگر مقدار داشته باشد فقط این پکیج محاسبه می‌شود، در غیر این صورت همه پکیج‌ها
/// </summary>
public long? PackageId { get; init; }
} }
@@ -53,6 +53,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
var balancesCount = await strategy.CalculateWeeklyBalancesAsync( var balancesCount = await strategy.CalculateWeeklyBalancesAsync(
request.WeekDefinitionId, request.WeekDefinitionId,
request.ForceRecalculate, request.ForceRecalculate,
request.PackageId,
cancellationToken); cancellationToken);
steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)"); steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
@@ -64,6 +65,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
var poolId = await strategy.CalculateWeeklyCommissionPoolAsync( var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
request.WeekDefinitionId, request.WeekDefinitionId,
request.ForceRecalculate, request.ForceRecalculate,
request.PackageId,
cancellationToken); cancellationToken);
steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})"); steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
@@ -20,6 +20,11 @@ public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
/// </summary> /// </summary>
public bool? OnlyCalculated { get; init; } public bool? OnlyCalculated { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary> /// <summary>
/// شماره صفحه /// شماره صفحه
/// </summary> /// </summary>
@@ -17,6 +17,7 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
{ {
var query = _context.WeeklyCommissionPools var query = _context.WeeklyCommissionPools
.Include(i=> i.WeekDefinition) .Include(i=> i.WeekDefinition)
.Include(i => i.Package)
.AsNoTracking(); .AsNoTracking();
// Apply filters // Apply filters
@@ -35,6 +36,11 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
query = query.Where(x => x.IsCalculated); query = query.Where(x => x.IsCalculated);
} }
if (request.PackageId.HasValue)
{
query = query.Where(x => x.PackageId == request.PackageId.Value);
}
// Order by week number descending (newest first) // Order by week number descending (newest first)
query = query.OrderByDescending(x => x.WeekDefinitionId); query = query.OrderByDescending(x => x.WeekDefinitionId);
@@ -55,7 +61,9 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
ValuePerBalance = x.ValuePerBalance, ValuePerBalance = x.ValuePerBalance,
IsCalculated = x.IsCalculated, IsCalculated = x.IsCalculated,
CalculatedAt = x.CalculatedAt, CalculatedAt = x.CalculatedAt,
Created = x.Created Created = x.Created,
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : string.Empty
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -17,6 +17,8 @@ public record WeeklyCommissionPoolDto
public bool IsCalculated { get; init; } public bool IsCalculated { get; init; }
public DateTime? CalculatedAt { get; init; } public DateTime? CalculatedAt { get; init; }
public DateTime Created { get; init; } public DateTime Created { get; init; }
public long PackageId { get; init; }
public string PackageTitle { get; init; } = string.Empty;
} }
public record MetaDataDto public record MetaDataDto
@@ -15,75 +15,49 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken) public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
{ {
var query = _context.NetworkWeeklyBalances var pageSize = request.PaginationState?.PageSize > 0 ? request.PaginationState.PageSize : PaginationDefaults.PageSize;
.Include(x => x.WeekDefinition) var pageNumber = request.PaginationState?.PageNumber > 0 ? request.PaginationState.PageNumber : PaginationDefaults.PageNumber;
.Include(x => x.User)
.Include(x => x.Package)
.AsNoTracking()
.AsQueryable();
// UserId > 0 → filter by that user // تشخیص جهت مرتب‌سازی از SortBy (پیش‌فرض: نزولی)
// UserId == 0 or null → show ALL users (admin mode) var sortBy = request.SortBy ?? "-WeekDefinitionId";
// Customer endpoints resolve UserId from JWT before calling this handler int sortDescending = sortBy.StartsWith("-") ? 1 : 0;
long? userId = request.UserId;
if (userId.HasValue && userId.Value > 0) long? userId = request.UserId.HasValue && request.UserId.Value > 0 ? request.UserId : null;
{ long? weekDefinitionId = request.WeekDefinitionId.HasValue ? request.WeekDefinitionId : null;
query = query.Where(x => x.UserId == userId.Value); long? packageId = request.PackageId.HasValue && request.PackageId.Value > 0 ? request.PackageId : null;
} int onlyActive = request.OnlyActive.HasValue && request.OnlyActive.Value ? 1 : 0;
// فیلتر بر اساس WeekDefinitionId (روش ترجیحی) var rows = await _context.Database
if (request.WeekDefinitionId.HasValue) .SqlQuery<GetUserWeeklyBalancesResponseModel>(
{ $"""
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value); EXEC [CMS].[sp_GetUserWeeklyBalances]
} @UserId = {userId},
@WeekDefinitionId = {weekDefinitionId},
@PackageId = {packageId},
if (request.OnlyActive.HasValue && request.OnlyActive.Value) @OnlyActive = {onlyActive},
{ @SortDescending = {sortDescending},
query = query.Where(x => !x.IsExpired); @PageNumber = {pageNumber},
} @PageSize = {pageSize}
""")
// فیلتر بر اساس پکیج
if (request.PackageId.HasValue && request.PackageId.Value > 0)
{
query = query.Where(x => x.PackageId == request.PackageId.Value);
}
// مرتب‌سازی بر اساس WeekDefinitionId (نزولی = جدیدترین اول)
query = query.ApplyOrder(sortBy: request.SortBy ?? "-WeekDefinitionId");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
var models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetUserWeeklyBalancesResponseModel
{
Id = x.Id,
UserId = x.UserId,
UserFullName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}".Trim() : "",
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName = x.WeekDefinition != null ? x.WeekDefinition.DisplayName : "",
LeftLegNewMembers = x.LeftLegNewMembers,
LeftLegCarryover = x.LeftLegCarryover,
LeftLegTotal = x.LeftLegTotal,
RightLegNewMembers = x.RightLegNewMembers,
RightLegCarryover = x.RightLegCarryover,
RightLegTotal = x.RightLegTotal,
TotalBalances = x.TotalBalances,
WeeklyPoolContribution = x.WeeklyPoolContribution,
CalculatedAt = x.CalculatedAt,
IsExpired = x.IsExpired,
Created = x.Created,
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : ""
})
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var totalCount = rows.Count > 0 ? rows[0].TotalCount : 0;
var totalPageCount = (int)Math.Ceiling(totalCount / (double)pageSize);
var meta = new MetaData
{
CurrentPage = pageNumber,
PageSize = pageSize,
TotalCount = totalCount,
TotalPage = totalPageCount,
HasNext = pageNumber < totalPageCount,
HasPrevious = pageNumber > 1
};
return new GetUserWeeklyBalancesResponseDto return new GetUserWeeklyBalancesResponseDto
{ {
MetaData = meta, MetaData = meta,
Models = models Models = rows
}; };
} }
} }
@@ -33,4 +33,7 @@ public class GetUserWeeklyBalancesResponseModel
// Package info // Package info
public long PackageId { get; set; } public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty; public string PackageTitle { get; set; } = string.Empty;
// نتیجه COUNT(*) OVER() از SP — فقط در خواندن از SP پر می‌شود
public int TotalCount { get; set; }
} }
@@ -9,4 +9,9 @@ public record GetWeeklyCommissionPoolQuery : IRequest<WeeklyCommissionPoolDto?>
/// شماره هفته /// شماره هفته
/// </summary> /// </summary>
public long WeekDefinitionId { get; init; } public long WeekDefinitionId { get; init; }
/// <summary>
/// شناسه پکیج — اختیاری
/// </summary>
public long? PackageId { get; init; }
} }
@@ -15,29 +15,87 @@ public class GetWeeklyCommissionPoolQueryHandler : IRequestHandler<GetWeeklyComm
public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken) public async Task<WeeklyCommissionPoolDto?> Handle(GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
{ {
var pool = await _context.WeeklyCommissionPools var query = _context.WeeklyCommissionPools
.Include(x => x.WeekDefinition) .Include(x => x.WeekDefinition)
.Include(x => x.Package)
.AsNoTracking() .AsNoTracking()
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId) .Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
if (request.PackageId.HasValue)
query = query.Where(x => x.PackageId == request.PackageId.Value);
var pool = await query
.Select(x => new WeeklyCommissionPoolDto .Select(x => new WeeklyCommissionPoolDto
{ {
Id = x.Id, Id = x.Id,
WeekDefinitionId = x.WeekDefinitionId, WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName=x.WeekDefinition.DisplayName, WeekDisplayName = x.WeekDefinition.DisplayName,
TotalPoolAmount = x.TotalPoolAmount, TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances, TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance, ValuePerBalance = x.ValuePerBalance,
IsCalculated = x.IsCalculated, IsCalculated = x.IsCalculated,
CalculatedAt = x.CalculatedAt, CalculatedAt = x.CalculatedAt,
Created = x.Created Created = x.Created,
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : string.Empty
}) })
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
// if (pool != null) pool ??= new WeeklyCommissionPoolDto();
// {
// pool.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(pool.WeekNumber);
// }
return pool ?? new WeeklyCommissionPoolDto(); // ── Q29: اعتبارسنجی TotalPoolAmount در برابر فعال‌سازی‌های واقعی ──
await ValidatePoolAmountAsync(pool, request, cancellationToken);
return pool;
}
/// <summary>
/// TotalPoolAmount استخر را با تعداد فعال‌سازی‌های ثبت‌شده (AcceptContract + ActivateClubMembership) مقایسه می‌کند.
/// نتیجه در فیلدهای Validation... ذخیره می‌شود.
/// </summary>
private async Task ValidatePoolAmountAsync(WeeklyCommissionPoolDto pool, GetWeeklyCommissionPoolQuery request, CancellationToken cancellationToken)
{
var packageId = pool.PackageId > 0 ? pool.PackageId : request.PackageId;
if (packageId is null or 0)
return;
var weekDef = _weekDefinitionRepository.GetWeekById(request.WeekDefinitionId);
if (weekDef is null)
return;
var activationFee = await _context.Packages
.AsNoTracking()
.Where(p => p.Id == packageId.Value)
.Select(p => p.ActivationFee)
.FirstOrDefaultAsync(cancellationToken);
if (activationFee <= 0)
return;
// شمارش Cycle‌هایی که Pool آن‌ها واقعاً شارژ شده:
// - عضو جدید: Pool توسط AcceptContract شارژ می‌شود → UserContract وجود دارد
// - خرید مجدد: Pool در ActivateClubMembership شارژ می‌شود → IsCurrentCycle نبوده (قبلاً active بوده)
// - نباید از IsActive استفاده شود: وضعیت تاریخی نیست (عضو ممکن است بعداً غیرفعال شده باشد)
//
// قانون: Cycle شارژ شده = یا قرارداد دارد (عضو جدید) یا IsActive قبلاً true بوده (خرید مجدد)
// ساده‌ترین معادل: Cycle هایی که ClubMembership.ActivatedAt در بازه هفته است (زمان واقعی شارژ Pool)
var totalActivations = await _context.ClubMembershipCycles
.AsNoTracking()
.Where(c => c.PackageId == packageId.Value
&& c.PackagePurchasedAt >= weekDef.StartDate
&& c.PackagePurchasedAt < weekDef.EndDate
&& _context.UserContracts.Any(uc =>
uc.UserId == c.UserId
&& _context.Contracts.Any(con =>
con.Id == uc.ContractId
&& con.Type == ContractType.ClubMembership)))
.CountAsync(cancellationToken);
var expectedAmount = (long)totalActivations * activationFee;
pool.ValidationActivationCount = totalActivations;
pool.ValidationExpectedAmount = expectedAmount;
pool.ValidationDiscrepancy = pool.TotalPoolAmount - expectedAmount;
pool.ValidationIsValid = pool.TotalPoolAmount == expectedAmount;
} }
} }
@@ -14,4 +14,16 @@ public class WeeklyCommissionPoolDto
public bool IsCalculated { get; set; } public bool IsCalculated { get; set; }
public DateTime? CalculatedAt { get; set; } public DateTime? CalculatedAt { get; set; }
public DateTimeOffset Created { get; set; } public DateTimeOffset Created { get; set; }
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
// ── Validation Fields (Q29) ──
/// <summary>آیا TotalPoolAmount با تعداد فعال‌سازی‌های ثبت‌شده در دیتابیس مطابقت دارد؟</summary>
public bool ValidationIsValid { get; set; } = true;
/// <summary>مبلغ مورد انتظار بر اساس شمارش فعال‌سازی‌ها × ActivationFee</summary>
public long ValidationExpectedAmount { get; set; }
/// <summary>اختلاف: TotalPoolAmount - ValidationExpectedAmount</summary>
public long ValidationDiscrepancy { get; set; }
/// <summary>تعداد فعال‌سازی‌های شناسایی‌شده برای این هفته و پکیج</summary>
public int ValidationActivationCount { get; set; }
} }
@@ -0,0 +1,85 @@
using CMSMicroservice.Application.Common.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.Common;
public static class BlogSlugHelper
{
private const int MaxSlugLength = 200;
public static string? NormalizeOptional(string? slug)
{
if (string.IsNullOrWhiteSpace(slug))
return null;
return slug.Trim().ToLowerInvariant();
}
/// <summary>
/// Builds a URL slug from title (ASCII letters/digits). Persian-only titles yield empty string.
/// </summary>
public static string GenerateFromTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
return string.Empty;
var sb = new System.Text.StringBuilder();
var lastWasHyphen = false;
foreach (var c in title.Trim().ToLowerInvariant())
{
if (c is >= 'a' and <= 'z' or >= '0' and <= '9')
{
sb.Append(c);
lastWasHyphen = false;
}
else if (c is ' ' or '_' or '-')
{
if (sb.Length > 0 && !lastWasHyphen)
{
sb.Append('-');
lastWasHyphen = true;
}
}
}
var result = sb.ToString().Trim('-');
return result.Length > MaxSlugLength ? result[..MaxSlugLength].TrimEnd('-') : result;
}
public static string CreateFallbackSlug() =>
$"post-{Guid.NewGuid():N}"[..Math.Min(20, MaxSlugLength)];
public static string ResolveForCreate(string? requestedSlug, string title)
{
var normalized = NormalizeOptional(requestedSlug);
if (!string.IsNullOrEmpty(normalized))
return normalized;
var fromTitle = GenerateFromTitle(title);
return !string.IsNullOrEmpty(fromTitle) ? fromTitle : CreateFallbackSlug();
}
public static async Task<string> EnsureUniquePostSlugAsync(
IApplicationDbContext context,
string slug,
long? excludePostId = null,
CancellationToken cancellationToken = default)
{
var candidate = slug;
var suffix = 2;
while (await context.BlogPosts.AnyAsync(
p => p.Slug == candidate && !p.IsDeleted && (excludePostId == null || p.Id != excludePostId.Value),
cancellationToken))
{
var suffixStr = $"-{suffix}";
var maxBase = MaxSlugLength - suffixStr.Length;
var basePart = slug.Length > maxBase ? slug[..maxBase].TrimEnd('-') : slug;
candidate = basePart + suffixStr;
suffix++;
}
return candidate;
}
}
@@ -0,0 +1,22 @@
namespace CMSMicroservice.Application.Common.Exceptions;
/// <summary>
/// Thrown when a concurrent payment operation is already in progress for the same scope.
/// </summary>
public class PaymentInProgressException : Exception
{
public PaymentInProgressException()
: base("یک عملیات پرداخت دیگر در حال پردازش است. لطفاً چند لحظه صبر کنید.")
{
}
public PaymentInProgressException(string message)
: base(message)
{
}
public PaymentInProgressException(string message, Exception innerException)
: base(message, innerException)
{
}
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using System.Linq.Dynamic.Core; using System.Linq.Dynamic.Core;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
@@ -8,16 +9,46 @@ public static class SortByExtensions
public static IQueryable<TSource> ApplyOrder<TSource>(this IQueryable<TSource> source, public static IQueryable<TSource> ApplyOrder<TSource>(this IQueryable<TSource> source,
string? sortBy) where TSource : BaseAuditableEntity string? sortBy) where TSource : BaseAuditableEntity
{ {
// default sort approach
if (sortBy is null or "") if (sortBy is null or "")
{ {
source = source.OrderByDescending(p => p.Created); return source.OrderByDescending(p => p.Created);
return source;
} }
// sort using dynamic linq return source.OrderBy(NormalizeSortBy(sortBy));
source = source.OrderBy(sortBy); }
return source; /// <summary>
/// موجودی‌دارها اول، سپس سورت درخواستی کاربر (یا Created desc اگر SortBy خالی باشد).
/// </summary>
public static IQueryable<TSource> ApplyOrderWithInStockPriority<TSource>(
this IQueryable<TSource> source,
string? sortBy,
Expression<Func<TSource, bool>> isInStock) where TSource : BaseAuditableEntity
{
var ordered = source.OrderByDescending(isInStock);
if (string.IsNullOrWhiteSpace(sortBy))
return ordered.ThenByDescending(p => p.Created);
return ordered.ThenBy(NormalizeSortBy(sortBy));
}
/// <summary>
/// Dynamic LINQ treats "-Created" as unary minus on DateTime (invalid).
/// Project convention: leading '-' means descending sort.
/// </summary>
private static string NormalizeSortBy(string sortBy)
{
sortBy = sortBy.Trim();
if (sortBy.StartsWith('-') && sortBy.Length > 1)
{
return $"{sortBy[1..]} descending";
}
if (sortBy.StartsWith('+') && sortBy.Length > 1)
{
return $"{sortBy[1..]} ascending";
}
return sortBy;
} }
} }
@@ -0,0 +1,54 @@
using CMSMicroservice.Domain.Entities.Club;
using CMSMicroservice.Domain.Entities.History;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.Common.Helpers;
public static class ClubMembershipCycleHistoryWriter
{
public static void WriteCycleStarted(
IApplicationDbContext context,
ClubMembershipCycle cycle,
string? performedBy = "System",
string? reason = null)
{
context.ClubMembershipCycleHistories.Add(new ClubMembershipCycleHistory
{
ClubMembershipCycleId = cycle.Id,
UserId = cycle.UserId,
CycleNumber = cycle.CycleNumber,
OldIsCurrentCycle = false,
NewIsCurrentCycle = cycle.IsCurrentCycle,
OldMagicStartedAt = null,
NewMagicStartedAt = cycle.MagicStartedAt,
OldMagicCompletedAt = null,
NewMagicCompletedAt = cycle.MagicCompletedAt,
Action = ClubMembershipCycleAction.Started,
PerformedBy = performedBy,
Reason = reason
});
}
public static void WriteCycleClosed(
IApplicationDbContext context,
ClubMembershipCycle cycle,
string? performedBy = "System",
string? reason = null)
{
context.ClubMembershipCycleHistories.Add(new ClubMembershipCycleHistory
{
ClubMembershipCycleId = cycle.Id,
UserId = cycle.UserId,
CycleNumber = cycle.CycleNumber,
OldIsCurrentCycle = true,
NewIsCurrentCycle = false,
OldMagicStartedAt = cycle.MagicStartedAt,
NewMagicStartedAt = cycle.MagicStartedAt,
OldMagicCompletedAt = cycle.MagicCompletedAt,
NewMagicCompletedAt = cycle.MagicCompletedAt,
Action = ClubMembershipCycleAction.Completed,
PerformedBy = performedBy,
Reason = reason ?? "دوره جدید جایگزین شد"
});
}
}
@@ -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;
}
@@ -11,11 +11,13 @@ public interface ICommissionCalculationStrategy
/// </summary> /// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param> /// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param> /// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="packageId">اگر مقدار داشته باشد فقط این پکیج محاسبه می‌شود — در غیر این صورت همه پکیج‌ها</param>
/// <param name="cancellationToken">توکن لغو</param> /// <param name="cancellationToken">توکن لغو</param>
/// <returns>تعداد رکوردهای محاسبه شده</returns> /// <returns>تعداد رکوردهای محاسبه شده</returns>
Task<int> CalculateWeeklyBalancesAsync( Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId, long weekDefinitionId,
bool forceRecalculate, bool forceRecalculate,
long? packageId = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
@@ -23,11 +25,13 @@ public interface ICommissionCalculationStrategy
/// </summary> /// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param> /// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param> /// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="packageId">اگر مقدار داشته باشد فقط این پکیج محاسبه می‌شود — در غیر این صورت همه پکیج‌ها</param>
/// <param name="cancellationToken">توکن لغو</param> /// <param name="cancellationToken">توکن لغو</param>
/// <returns>شناسه Pool ایجاد شده</returns> /// <returns>شناسه Pool ایجاد شده</returns>
Task<long> CalculateWeeklyCommissionPoolAsync( Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId, long weekDefinitionId,
bool forceRecalculate, bool forceRecalculate,
long? packageId = null,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }
@@ -47,6 +47,17 @@ public interface IPaymentGatewayService
"درگاه پرداخت باید متد VerifyPaymentAsync با مبلغ را پیاده‌سازی کند"); "درگاه پرداخت باید متد VerifyPaymentAsync با مبلغ را پیاده‌سازی کند");
} }
/// <summary>
/// فهرست Authority‌هایی که کاربر پرداخت کرده ولی سیستم هنوز Verify نکرده
/// این متد برای Reconciliation در background job استفاده می‌شود.
/// پیاده‌سازی پیش‌فرض لیست خالی برمی‌گرداند (درگاه‌هایی که این API را ندارند).
/// </summary>
Task<IReadOnlyList<string>> GetUnverifiedAuthoritiesAsync(
CancellationToken cancellationToken = default)
{
return Task.FromResult<IReadOnlyList<string>>(Array.Empty<string>());
}
/// <summary> /// <summary>
/// واریز مبلغ به حساب کاربر (برداشت از کیف پول) /// واریز مبلغ به حساب کاربر (برداشت از کیف پول)
/// </summary> /// </summary>
@@ -0,0 +1,32 @@
namespace CMSMicroservice.Application.Common.Interfaces;
/// <summary>
/// Per-scope in-process mutex for payment flows (initiate / verify).
/// Prevents the same user from running duplicate gateway operations concurrently.
/// </summary>
public interface IUserPaymentLock
{
Task<T> ExecuteAsync<T>(
string scope,
PaymentLockStrategy strategy,
Func<CancellationToken, Task<T>> action,
CancellationToken cancellationToken = default);
Task ExecuteAsync(
string scope,
PaymentLockStrategy strategy,
Func<CancellationToken, Task> action,
CancellationToken cancellationToken = default);
}
/// <summary>
/// How to behave when the payment lock is already held.
/// </summary>
public enum PaymentLockStrategy
{
/// <summary>Wait up to the configured timeout (callback / verify paths).</summary>
WaitForRelease,
/// <summary>Reject immediately (initiate / double-click paths).</summary>
FailFast
}
@@ -0,0 +1,19 @@
namespace CMSMicroservice.Application.Common;
/// <summary>
/// Canonical scope keys for <see cref="Interfaces.IUserPaymentLock"/>.
/// </summary>
public static class PaymentLockScopes
{
/// <summary>One active payment initiation per user (package, wallet charge, order, IPG deposit).</summary>
public static string Initiate(long userId) => $"payment:initiate:user:{userId}";
/// <summary>One active verify per user + authority/order (duplicate callback protection).</summary>
public static string Verify(long userId, string resourceKey)
{
if (string.IsNullOrWhiteSpace(resourceKey))
throw new ArgumentException("Payment verify resource key is required.", nameof(resourceKey));
return $"payment:verify:user:{userId}:{resourceKey.Trim()}";
}
}
@@ -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);
}
}
@@ -1,3 +1,5 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Services; using CMSMicroservice.Application.Common.Services;
using CMSMicroservice.Domain.Entities.DiscountShop; using CMSMicroservice.Domain.Entities.DiscountShop;
@@ -17,22 +19,32 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
private readonly IPaymentGatewayService _paymentGateway; private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<PlaceOrderCommandHandler> _logger; private readonly ILogger<PlaceOrderCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public PlaceOrderCommandHandler( public PlaceOrderCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
IInventoryService inventoryService, IInventoryService inventoryService,
IPaymentGatewayService paymentGateway, IPaymentGatewayService paymentGateway,
IConfiguration configuration, IConfiguration configuration,
ILogger<PlaceOrderCommandHandler> logger) ILogger<PlaceOrderCommandHandler> logger,
IUserPaymentLock paymentLock)
{ {
_context = context; _context = context;
_inventoryService = inventoryService; _inventoryService = inventoryService;
_paymentGateway = paymentGateway; _paymentGateway = paymentGateway;
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
_paymentLock = paymentLock;
} }
public async Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken) public Task<PlaceOrderResponseDto> Handle(PlaceOrderCommand request, CancellationToken cancellationToken) =>
_paymentLock.ExecuteAsync(
PaymentLockScopes.Initiate(request.UserId),
PaymentLockStrategy.FailFast,
ct => HandleCore(request, ct),
cancellationToken);
private async Task<PlaceOrderResponseDto> HandleCore(PlaceOrderCommand request, CancellationToken cancellationToken)
{ {
// Get user wallet // Get user wallet
var userWallet = await _context.UserWallets var userWallet = await _context.UserWallets
@@ -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,16 +81,40 @@ 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
{
o.Id,
o.UserId,
FirstName = o.User.FirstName,
LastName = o.User.LastName,
Mobile = o.User.Mobile,
o.TotalAmount,
o.DiscountBalanceUsed,
o.GatewayAmountPaid,
o.VatAmount,
o.PaymentStatus,
o.PaymentDate,
o.DeliveryStatus,
ShippingAddress = o.UserAddress.Address,
ReceiverName = o.UserAddress.Title,
o.TrackingCode,
AdminNote = o.DeliveryDescription,
o.Created,
o.LastModified,
ItemsCount = o.OrderDetails.Count
})
.ToListAsync(cancellationToken);
var orders = rows.Select(o => new AdminOrderDto
{ {
Id = o.Id, Id = o.Id,
UserId = o.UserId, UserId = o.UserId,
UserFullName = (o.User.FirstName ?? "") + " " + (o.User.LastName ?? ""), UserFullName = UserDisplayName.From(o.FirstName, o.LastName, o.Mobile, o.UserId),
UserMobile = o.User.Mobile, UserMobile = o.Mobile,
TotalAmount = o.TotalAmount, TotalAmount = o.TotalAmount,
DiscountBalanceUsed = o.DiscountBalanceUsed, DiscountBalanceUsed = o.DiscountBalanceUsed,
GatewayAmountPaid = o.GatewayAmountPaid, GatewayAmountPaid = o.GatewayAmountPaid,
@@ -97,17 +122,16 @@ public class GetAllDiscountOrdersQueryHandler : IRequestHandler<GetAllDiscountOr
PaymentStatus = o.PaymentStatus, PaymentStatus = o.PaymentStatus,
PaymentDate = o.PaymentDate, PaymentDate = o.PaymentDate,
DeliveryStatus = o.DeliveryStatus, DeliveryStatus = o.DeliveryStatus,
DeliveryDate = null, // TODO: Add DeliveryDate to DiscountOrder if needed DeliveryDate = null,
ShippingAddress = o.UserAddress.Address, ShippingAddress = o.ShippingAddress,
ReceiverName = o.UserAddress.Title, ReceiverName = o.ReceiverName,
ReceiverMobile = o.User.Mobile, ReceiverMobile = o.Mobile,
TrackingCode = o.TrackingCode, TrackingCode = o.TrackingCode,
AdminNote = o.DeliveryDescription, AdminNote = o.AdminNote,
Created = o.Created, Created = o.Created,
LastModified = o.LastModified, LastModified = o.LastModified,
ItemsCount = o.OrderDetails.Count ItemsCount = o.ItemsCount
}) }).ToList();
.ToListAsync(cancellationToken);
return new GetAllDiscountOrdersResponseDto return new GetAllDiscountOrdersResponseDto
{ {
@@ -8,9 +8,14 @@ public class GetDiscountProductsQuery : IRequest<GetDiscountProductsResponseDto>
public PaginationState? PaginationQuery { get; set; } public PaginationState? PaginationQuery { get; set; }
public long? CategoryId { get; set; } public long? CategoryId { get; set; }
public string? SearchTerm { get; set; } public string? SearchTerm { get; set; }
/// <summary>
/// true = فقط موجود (RemainingCount &gt; 0)، false = فقط ناموجود
/// </summary>
public bool? InStock { get; set; }
public bool? IsActive { get; set; } public bool? IsActive { get; set; }
public int? MinPrice { get; set; } public int? MinPrice { get; set; }
public int? MaxPrice { get; set; } public int? MaxPrice { get; set; }
public string? SortBy { get; set; }
} }
public class GetDiscountProductsResponseDto public class GetDiscountProductsResponseDto
@@ -1,3 +1,4 @@
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;
using MediatR; using MediatR;
@@ -41,6 +42,11 @@ public class GetDiscountProductsQueryHandler : IRequestHandler<GetDiscountProduc
query = query.Where(p => p.IsActive == request.IsActive.Value); query = query.Where(p => p.IsActive == request.IsActive.Value);
} }
if (request.InStock == true)
query = query.Where(p => p.RemainingCount > 0);
else if (request.InStock == false)
query = query.Where(p => p.RemainingCount <= 0);
if (request.MinPrice.HasValue) if (request.MinPrice.HasValue)
{ {
query = query.Where(p => p.Price >= request.MinPrice.Value); query = query.Where(p => p.Price >= request.MinPrice.Value);
@@ -51,13 +57,14 @@ public class GetDiscountProductsQueryHandler : IRequestHandler<GetDiscountProduc
query = query.Where(p => p.Price <= request.MaxPrice.Value); query = query.Where(p => p.Price <= request.MaxPrice.Value);
} }
// موجودی‌دارها اول، سپس سورت درخواستی
query = query.ApplyOrderWithInStockPriority(request.SortBy, p => p.RemainingCount > 0);
var totalCount = await query.CountAsync(cancellationToken); var totalCount = await query.CountAsync(cancellationToken);
// Apply pagination
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 10 }; var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 10 };
var products = await query var products = await query
.OrderByDescending(p => p.Created)
.Skip((pagination.PageNumber - 1) * pagination.PageSize) .Skip((pagination.PageNumber - 1) * pagination.PageSize)
.Take(pagination.PageSize) .Take(pagination.PageSize)
.Select(p => new DiscountProductDto .Select(p => new DiscountProductDto
@@ -6,7 +6,7 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetOrderById;
public class GetOrderByIdQuery : IRequest<OrderDetailDto?> public class GetOrderByIdQuery : IRequest<OrderDetailDto?>
{ {
public long OrderId { get; set; } public long OrderId { get; set; }
public long UserId { get; set; } public long? UserId { get; set; }
} }
public class OrderDetailDto public class OrderDetailDto
@@ -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
@@ -15,9 +15,17 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
public async Task<OrderDetailDto?> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken) public async Task<OrderDetailDto?> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
{ {
var order = await _context.DiscountOrders var query = _context.DiscountOrders
.Where(o => o.Id == request.OrderId && o.UserId == request.UserId) .Where(o => o.Id == request.OrderId);
if (request.UserId.HasValue && request.UserId.Value > 0)
{
query = query.Where(o => o.UserId == request.UserId.Value);
}
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);
@@ -43,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
{ {
@@ -14,10 +14,15 @@ public class CreateManualPaymentCommand : IRequest<long>
public long UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// مبلغ تراکنش (تومان) /// مبلغ تراکنش (تومان) — [DEPRECATED] مبلغ از پکیج خوانده می‌شود
/// </summary> /// </summary>
public long Amount { get; set; } public long Amount { get; set; }
/// <summary>
/// شناسه پکیج — اگر 0 باشد، پکیج پایه استفاده می‌شود
/// </summary>
public long PackageId { get; set; }
/// <summary> /// <summary>
/// نوع تراکنش دستی /// نوع تراکنش دستی
/// </summary> /// </summary>
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
using CMSMicroservice.Application.Common.Exceptions; using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces; using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common; using CMSMicroservice.Domain.Common;
@@ -14,15 +15,18 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser; private readonly ICurrentUserService _currentUser;
private readonly ISender _sender;
private readonly ILogger<CreateManualPaymentCommandHandler> _logger; private readonly ILogger<CreateManualPaymentCommandHandler> _logger;
public CreateManualPaymentCommandHandler( public CreateManualPaymentCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
ICurrentUserService currentUser, ICurrentUserService currentUser,
ISender sender,
ILogger<CreateManualPaymentCommandHandler> logger) ILogger<CreateManualPaymentCommandHandler> logger)
{ {
_context = context; _context = context;
_currentUser = currentUser; _currentUser = currentUser;
_sender = sender;
_logger = logger; _logger = logger;
} }
@@ -70,10 +74,20 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
throw new NotFoundException($"کیف پول کاربر {request.UserId} یافت نشد"); throw new NotFoundException($"کیف پول کاربر {request.UserId} یافت نشد");
} }
// 4. بارگذاری پکیج پایه و محاسبه مبالغ // 4. بارگذاری پکیج انتخاب‌شده (یا پکیج پایه اگر PackageId=0)
var package = await _context.Packages Domain.Entities.Package package;
if (request.PackageId > 0)
{
package = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == request.PackageId && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException($"پکیج با شناسه {request.PackageId} یافت نشد");
}
else
{
package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken) .FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException("پکیج پایه یافت نشد"); ?? throw new NotFoundException("پکیج پایه یافت نشد");
}
var balanceAmount = package.Price; var balanceAmount = package.Price;
var discountBalanceAmount = (long)(package.Price * package.DiscountMultiplier); var discountBalanceAmount = (long)(package.Price * package.DiscountMultiplier);
@@ -136,12 +150,45 @@ 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);
// 11. فعال‌سازی باشگاه مشتریان — ساخت Cycle و آماده‌سازی برای AcceptContract
// این مرحله تضمین می‌کند که:
// الف) ClubMembershipCycle ساخته شود تا والدین امتیاز «عضو جدید» بگیرند
// ب) Pool توسط AcceptContract (که کاربر در FO امضا می‌کند) شارژ شود
try
{
await _sender.Send(new ActivateClubMembershipCommand
{
UserId = request.UserId,
ForceActivation = true,
PackageId = package.Id
}, cancellationToken);
}
catch (Exception activateEx)
{
// عضویت باشگاه اختیاری است — خطا نباید پرداخت دستی را برگرداند
_logger.LogWarning(
activateEx,
"Club membership activation failed after manual payment for UserId {UserId} — payment recorded successfully",
request.UserId
);
}
_logger.LogInformation( _logger.LogInformation(
"Manual membership payment created successfully. " + "Manual membership payment created successfully. " +
"ManualPaymentId: {Id}, UserId: {UserId}, TransactionId: {TransactionId}, " + "ManualPaymentId: {Id}, UserId: {UserId}, TransactionId: {TransactionId}, " +
@@ -11,12 +11,10 @@ public class CreateManualPaymentCommandValidator : AbstractValidator<CreateManua
.GreaterThan(0) .GreaterThan(0)
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد"); .WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
// حصار ایمنی — مبلغ واقعی از پکیج کاربر خوانده می‌شود // مبلغ از پکیج خوانده می‌شود — فیلد Amount دیگر اعتبارسنجی نمی‌شود
RuleFor(x => x.Amount) RuleFor(x => x.PackageId)
.GreaterThan(0) .GreaterThanOrEqualTo(0)
.WithMessage("مبلغ باید بزرگتر از صفر باشد") .WithMessage("شناسه پکیج نامعتبر است");
.LessThanOrEqualTo(SystemConstants.WalletMaxSafeAmount)
.WithMessage("مبلغ وارد شده بیش از حد مجاز است");
RuleFor(x => x.Type) RuleFor(x => x.Type)
.IsInEnum() .IsInEnum()
@@ -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);
@@ -70,7 +70,7 @@ public class GetAllManualPaymentsQueryHandler
// Pagination // Pagination
var skip = (request.PageNumber - 1) * request.PageSize; var skip = (request.PageNumber - 1) * request.PageSize;
// دریافت PackageName از آخرین خرید پکیج کاربر // دریافت PackageName پیش‌فرض از آخرین خرید پکیج کاربر (fallback برای داده‌های قدیمی)
var userPackageNames = await _context.UserPackagePurchases var userPackageNames = await _context.UserPackagePurchases
.Include(p => p.Package) .Include(p => p.Package)
.GroupBy(p => p.UserId) .GroupBy(p => p.UserId)
@@ -104,12 +104,45 @@ public class GetAllManualPaymentsQueryHandler
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
// PackageName دقیق هر پرداخت دستی از UserWalletHistories بر اساس TransactionId همان پرداخت
var transactionIds = data
.Where(x => x.TransactionId.HasValue)
.Select(x => x.TransactionId!.Value)
.Distinct()
.ToList();
var packageNamesByTransaction = new Dictionary<long, string>();
if (transactionIds.Count > 0)
{
packageNamesByTransaction = await _context.UserWalletHistories
.AsNoTracking()
.Include(h => h.Package)
.Where(h => h.RefrenceId.HasValue
&& transactionIds.Contains(h.RefrenceId.Value)
&& h.PackageId.HasValue)
.GroupBy(h => h.RefrenceId!.Value)
.Select(g => new
{
TransactionId = g.Key,
PackageName = g.OrderByDescending(x => x.Created)
.Select(x => x.Package != null ? x.Package.Title : string.Empty)
.FirstOrDefault()
})
.ToDictionaryAsync(x => x.TransactionId, x => x.PackageName ?? string.Empty, cancellationToken);
}
// تکمیل PackageName // تکمیل PackageName
foreach (var item in data) foreach (var item in data)
{ {
if (userPackageNames.TryGetValue(item.UserId, out var pkgName)) if (item.TransactionId.HasValue && packageNamesByTransaction.TryGetValue(item.TransactionId.Value, out var txPkgName))
{
item.PackageName = txPkgName;
}
else if (userPackageNames.TryGetValue(item.UserId, out var pkgName))
{
item.PackageName = pkgName; item.PackageName = pkgName;
} }
}
var metaData = new MetaData var metaData = new MetaData
{ {
@@ -118,7 +118,10 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
ActivationWeekDefinitionId = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDefinitionId")) ? null : reader.GetInt64(reader.GetOrdinal("ActivationWeekDefinitionId")), ActivationWeekDefinitionId = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDefinitionId")) ? null : reader.GetInt64(reader.GetOrdinal("ActivationWeekDefinitionId")),
ActivationWeekDisplayName = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDisplayName")) ? null : reader.GetString(reader.GetOrdinal("ActivationWeekDisplayName")), ActivationWeekDisplayName = reader.IsDBNull(reader.GetOrdinal("ActivationWeekDisplayName")) ? null : reader.GetString(reader.GetOrdinal("ActivationWeekDisplayName")),
IsActivatedInTargetWeek = Convert.ToBoolean(reader.GetValue(reader.GetOrdinal("IsActivatedInTargetWeek"))), IsActivatedInTargetWeek = Convert.ToBoolean(reader.GetValue(reader.GetOrdinal("IsActivatedInTargetWeek"))),
UserCreated = new DateTimeOffset(reader.GetDateTime(reader.GetOrdinal("UserCreated"))) UserCreated = new DateTimeOffset(reader.GetDateTime(reader.GetOrdinal("UserCreated"))),
IsNewActivation = reader.IsDBNull(reader.GetOrdinal("IsNewActivation")) ? null : (bool?)(reader.GetInt32(reader.GetOrdinal("IsNewActivation")) == 1),
PackageName = reader.IsDBNull(reader.GetOrdinal("PackageName")) ? null : reader.GetString(reader.GetOrdinal("PackageName")),
PackageId = reader.IsDBNull(reader.GetOrdinal("PackageId")) ? null : reader.GetInt64(reader.GetOrdinal("PackageId"))
}; };
results.Add(node); results.Add(node);
@@ -160,7 +163,10 @@ public class GetNetworkTreeQueryHandler : IRequestHandler<GetNetworkTreeQuery, N
ActivationWeekDefinitionId = flatNode.ActivationWeekDefinitionId, ActivationWeekDefinitionId = flatNode.ActivationWeekDefinitionId,
ActivationWeekDisplayName = flatNode.ActivationWeekDisplayName, ActivationWeekDisplayName = flatNode.ActivationWeekDisplayName,
IsActivatedInTargetWeek = flatNode.IsActivatedInTargetWeek, IsActivatedInTargetWeek = flatNode.IsActivatedInTargetWeek,
UserCreated = flatNode.UserCreated UserCreated = flatNode.UserCreated,
IsNewActivation = flatNode.IsNewActivation,
PackageName = flatNode.PackageName,
PackageId = flatNode.PackageId
}; };
} }
@@ -48,6 +48,18 @@ public class NetworkTreeDto
/// </summary> /// </summary>
public DateTimeOffset UserCreated { get; set; } public DateTimeOffset UserCreated { get; set; }
/// <summary>
/// آیا فعال‌سازی در هفته هدف اولین بار بوده؟ null = بدون Cycle
/// </summary>
public bool? IsNewActivation { get; set; }
/// <summary>
/// نام پکیج موثر (هفته هدف یا پکیج فعلی)
/// </summary>
public string? PackageName { get; set; }
public long? PackageId { get; set; }
public NetworkTreeDto? LeftChild { get; set; } public NetworkTreeDto? LeftChild { get; set; }
public NetworkTreeDto? RightChild { get; set; } public NetworkTreeDto? RightChild { get; set; }
} }
@@ -21,6 +21,19 @@ public class NetworkTreeNodeDto
public bool IsActivatedInTargetWeek { get; set; } public bool IsActivatedInTargetWeek { get; set; }
public DateTimeOffset UserCreated { get; set; } public DateTimeOffset UserCreated { get; set; }
/// <summary>
/// آیا فعال‌سازی در هفته هدف اولین بار بوده (true) یا خرید مجدد (false)؟
/// null یعنی Cycle‌ای ندارد
/// </summary>
public bool? IsNewActivation { get; set; }
/// <summary>
/// نام پکیج موثر (هفته هدف یا پکیج فعلی)
/// </summary>
public string? PackageName { get; set; }
public long? PackageId { get; set; }
/// <summary> /// <summary>
/// نام کامل کاربر /// نام کامل کاربر
/// </summary> /// </summary>
@@ -10,15 +10,18 @@ public class CancelOrderByAdminCommandHandler : IRequestHandler<CancelOrderByAdm
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser; private readonly ICurrentUserService _currentUser;
private readonly IInventoryService _inventoryService;
private readonly ILogger<CancelOrderByAdminCommandHandler> _logger; private readonly ILogger<CancelOrderByAdminCommandHandler> _logger;
public CancelOrderByAdminCommandHandler( public CancelOrderByAdminCommandHandler(
IApplicationDbContext context, IApplicationDbContext context,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IInventoryService inventoryService,
ILogger<CancelOrderByAdminCommandHandler> logger) ILogger<CancelOrderByAdminCommandHandler> logger)
{ {
_context = context; _context = context;
_currentUser = currentUser; _currentUser = currentUser;
_inventoryService = inventoryService;
_logger = logger; _logger = logger;
} }
@@ -33,6 +36,7 @@ public class CancelOrderByAdminCommandHandler : IRequestHandler<CancelOrderByAdm
var order = await _context.UserOrders var order = await _context.UserOrders
.Include(x => x.User) .Include(x => x.User)
.ThenInclude(x => x.UserWallets) .ThenInclude(x => x.UserWallets)
.Include(x => x.FactorDetails)
.FirstOrDefaultAsync(x => x.Id == request.OrderId && !x.IsDeleted, cancellationToken); .FirstOrDefaultAsync(x => x.Id == request.OrderId && !x.IsDeleted, cancellationToken);
if (order == null) if (order == null)
@@ -87,6 +91,26 @@ public class CancelOrderByAdminCommandHandler : IRequestHandler<CancelOrderByAdm
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Restore inventory for each item (only if order was not yet delivered)
if (order.FactorDetails != null && order.FactorDetails.Any())
{
foreach (var item in order.FactorDetails)
{
var restored = await _inventoryService.ProcessReturnAsync(
item.ProductId, ProductType.RegularProduct,
item.Count, order.Id,
note: $"برگشت موجودی — لغو سفارش #{order.Id} توسط Admin",
ct: cancellationToken);
if (!restored)
{
_logger.LogWarning(
"Failed to restore inventory for ProductId={ProductId} on cancel OrderId={OrderId}",
item.ProductId, order.Id);
}
}
}
_logger.LogInformation( _logger.LogInformation(
"Order cancelled by admin. OrderId: {OrderId}, Reason: {Reason}, Refunded: {Refunded}, Admin: {AdminId}", "Order cancelled by admin. OrderId: {OrderId}, Reason: {Reason}, Refunded: {Refunded}, Admin: {AdminId}",
order.Id, order.Id,
@@ -55,6 +55,9 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
if (user is null) if (user is null)
{ {
if (purpose == "login")
return new VerifyOtpTokenResponseDto() { Success = false, Message = "کاربری با این شماره موبایل وجود ندارد." };
if (request.ParentReferralCode == null) if (request.ParentReferralCode == null)
return new VerifyOtpTokenResponseDto() { Success = false, Message = "کد معرف الزامی است." }; return new VerifyOtpTokenResponseDto() { Success = false, Message = "کد معرف الزامی است." };
@@ -0,0 +1,26 @@
using MediatR;
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment;
/// <summary>
/// Shared verify + wallet credit path for package IPG payments (callback UI and reconciliation job).
/// </summary>
public class VerifyUserPackagePurchasePaymentCommand : IRequest<VerifyUserPackagePurchasePaymentResult>
{
public long PurchaseId { get; set; }
public string Authority { get; set; } = string.Empty;
public string Status { get; set; } = "OK";
}
public class VerifyUserPackagePurchasePaymentResult
{
public bool Success { get; init; }
public bool AlreadyPaid { get; init; }
public string Message { get; init; } = string.Empty;
public long TransactionId { get; init; }
public string ReferenceCode { get; init; } = string.Empty;
public long PackageId { get; init; }
public string PackageName { get; init; } = string.Empty;
public long AmountPaid { get; init; }
public DateTime PurchasedAt { get; init; }
}
@@ -0,0 +1,303 @@
using System.Data;
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities;
using CMSMicroservice.Domain.Entities.Payment;
using CMSMicroservice.Domain.Enums;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CMSMicroservice.Application.PackageCQ.Commands.VerifyUserPackagePurchasePayment;
public class VerifyUserPackagePurchasePaymentCommandHandler
: IRequestHandler<VerifyUserPackagePurchasePaymentCommand, VerifyUserPackagePurchasePaymentResult>
{
private readonly IApplicationDbContext _context;
private readonly IPaymentGatewayService _paymentGateway;
private readonly ISender _sender;
private readonly IUserPaymentLock _paymentLock;
private readonly ILogger<VerifyUserPackagePurchasePaymentCommandHandler> _logger;
public VerifyUserPackagePurchasePaymentCommandHandler(
IApplicationDbContext context,
IPaymentGatewayService paymentGateway,
ISender sender,
IUserPaymentLock paymentLock,
ILogger<VerifyUserPackagePurchasePaymentCommandHandler> logger)
{
_context = context;
_paymentGateway = paymentGateway;
_sender = sender;
_paymentLock = paymentLock;
_logger = logger;
}
public async Task<VerifyUserPackagePurchasePaymentResult> Handle(
VerifyUserPackagePurchasePaymentCommand request,
CancellationToken cancellationToken)
{
var purchaseUserId = await _context.UserPackagePurchases
.Where(p => p.Id == request.PurchaseId && !p.IsDeleted)
.Select(p => (long?)p.UserId)
.FirstOrDefaultAsync(cancellationToken);
if (purchaseUserId is null or 0)
throw new NotFoundException("سفارش یافت نشد");
var verifyKey = !string.IsNullOrEmpty(request.Authority)
? request.Authority
: request.PurchaseId.ToString();
return await _paymentLock.ExecuteAsync(
PaymentLockScopes.Verify(purchaseUserId.Value, verifyKey),
PaymentLockStrategy.WaitForRelease,
ct => HandleCoreAsync(request, ct),
cancellationToken);
}
private async Task<VerifyUserPackagePurchasePaymentResult> HandleCoreAsync(
VerifyUserPackagePurchasePaymentCommand request,
CancellationToken ct)
{
var purchase = await _context.UserPackagePurchases
.Include(p => p.Package)
.Where(p => p.Id == request.PurchaseId && !p.IsDeleted)
.FirstOrDefaultAsync(ct);
if (purchase == null)
throw new NotFoundException("سفارش یافت نشد");
var transaction = purchase.TransactionId.HasValue
? await _context.Transactions
.FirstOrDefaultAsync(t => t.Id == purchase.TransactionId.Value, ct)
: null;
var paymentTx = !string.IsNullOrEmpty(request.Authority)
? await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, ct)
: null;
if (IsPaymentAlreadyCompleted(transaction, paymentTx))
{
return BuildResult(purchase, transaction, paymentTx, alreadyPaid: true);
}
if (!string.Equals(request.Status, "OK", StringComparison.OrdinalIgnoreCase))
{
if (transaction != null && transaction.PaymentStatus != PaymentStatus.Success)
{
transaction.PaymentStatus = PaymentStatus.Reject;
await _context.SaveChangesAsync(ct);
}
return new VerifyUserPackagePurchasePaymentResult
{
Success = false,
Message = "پرداخت توسط کاربر لغو شد",
PackageId = purchase.PackageId,
PackageName = purchase.Package?.Title ?? string.Empty,
AmountPaid = purchase.Amount,
PurchasedAt = purchase.PurchasedAt
};
}
var amountInToman = paymentTx?.Amount ?? purchase.Amount;
var verifyResult = await _paymentGateway.VerifyPaymentAsync(
request.Authority, request.Status, (decimal)amountInToman, ct);
if (!verifyResult.IsSuccess)
{
if (paymentTx != null)
{
paymentTx.PaymentStatus = false;
paymentTx.VerificationStatusCode = verifyResult.VerificationCode;
paymentTx.VerificationStatusMessage = verifyResult.Message;
}
if (transaction != null)
transaction.PaymentStatus = PaymentStatus.Reject;
await _context.SaveChangesAsync(ct);
return new VerifyUserPackagePurchasePaymentResult
{
Success = false,
Message = verifyResult.Message ?? "خرید پکیج ناموفق بود",
TransactionId = transaction?.Id ?? 0,
PackageId = purchase.PackageId,
PackageName = purchase.Package?.Title ?? string.Empty,
AmountPaid = purchase.Amount,
PurchasedAt = purchase.PurchasedAt
};
}
await using var dbTx = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
try
{
if (purchase.TransactionId.HasValue)
{
transaction = await _context.Transactions
.FirstOrDefaultAsync(t => t.Id == purchase.TransactionId.Value, ct);
}
if (!string.IsNullOrEmpty(request.Authority))
{
paymentTx = await _context.PaymentTransactions
.FirstOrDefaultAsync(pt => pt.Authority == request.Authority, ct);
}
if (IsPaymentAlreadyCompleted(transaction, paymentTx))
{
await dbTx.CommitAsync(ct);
return BuildResult(purchase, transaction, paymentTx, alreadyPaid: true);
}
if (transaction == null)
throw new InvalidOperationException("تراکنش سفارش یافت نشد");
var alreadyCredited = await _context.UserWalletHistories
.AnyAsync(h => h.RefrenceId == transaction.Id && !h.IsDeleted, ct);
if (alreadyCredited)
{
transaction.PaymentStatus = PaymentStatus.Success;
transaction.PaymentDate ??= DateTime.UtcNow;
UpdatePaymentTransaction(paymentTx, verifyResult, markSuccess: true);
await _context.SaveChangesAsync(ct);
await dbTx.CommitAsync(ct);
return BuildResult(purchase, transaction, paymentTx, alreadyPaid: true);
}
UpdatePaymentTransaction(paymentTx, verifyResult, markSuccess: true);
transaction.PaymentStatus = PaymentStatus.Success;
transaction.PaymentDate = DateTime.UtcNow;
transaction.RefId = verifyResult.RefId;
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == purchase.UserId, ct);
if (wallet == null)
{
wallet = new UserWallet
{
UserId = purchase.UserId,
Balance = 0,
DiscountBalance = 0,
NetworkBalance = 0
};
_context.UserWallets.Add(wallet);
await _context.SaveChangesAsync(ct);
}
var discountMultiplier = purchase.Package?.DiscountMultiplier
?? throw new InvalidOperationException("پکیج سفارش یافت نشد");
var discountAmount = (long)(purchase.Amount * (double)discountMultiplier);
wallet.Balance += purchase.Amount;
wallet.DiscountBalance += discountAmount;
_context.UserWalletHistories.Add(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
ChangeValue = purchase.Amount,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = wallet.DiscountBalance,
ChangeDiscountValue = discountAmount,
IsIncrease = true,
RefrenceId = transaction.Id,
PackageId = purchase.PackageId
});
var user = await _context.Users
.FirstOrDefaultAsync(u => u.Id == purchase.UserId, ct);
if (user != null)
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
await _context.SaveChangesAsync(ct);
await dbTx.CommitAsync(ct);
}
catch
{
await dbTx.RollbackAsync(ct);
throw;
}
try
{
await _sender.Send(new ActivateClubMembershipCommand
{
UserId = purchase.UserId,
ForceActivation = false
}, ct);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Auto club activation failed after package verify. UserId={UserId}, PurchaseId={PurchaseId}",
purchase.UserId,
purchase.Id);
}
_logger.LogInformation(
"Package purchase verified. PurchaseId={PurchaseId}, UserId={UserId}, Authority={Authority}",
purchase.Id,
purchase.UserId,
request.Authority);
return BuildResult(
purchase, transaction, paymentTx, alreadyPaid: false, verifyResult.RefId);
}
private static bool IsPaymentAlreadyCompleted(Transaction? transaction, PaymentTransaction? paymentTx)
{
return transaction?.PaymentStatus == PaymentStatus.Success
|| paymentTx?.PaymentStatus == true;
}
private static void UpdatePaymentTransaction(
PaymentTransaction? paymentTx,
PaymentVerificationResult verifyResult,
bool markSuccess)
{
if (paymentTx == null) return;
paymentTx.PaymentStatus = markSuccess && verifyResult.IsSuccess;
paymentTx.VerificationStatusCode = verifyResult.VerificationCode;
paymentTx.VerificationStatusMessage = verifyResult.Message;
paymentTx.CardPan = verifyResult.CardPan;
paymentTx.CardHash = verifyResult.CardHash;
paymentTx.RefId = verifyResult.TrackingCode;
}
private static VerifyUserPackagePurchasePaymentResult BuildResult(
UserPackagePurchase purchase,
Transaction? transaction,
PaymentTransaction? paymentTx,
bool alreadyPaid,
string? referenceCode = null)
{
var refCode = referenceCode
?? paymentTx?.RefId
?? transaction?.RefId
?? string.Empty;
return new VerifyUserPackagePurchasePaymentResult
{
Success = true,
AlreadyPaid = alreadyPaid,
Message = alreadyPaid ? "پرداخت قبلاً تأیید شده بود" : "خرید پکیج با موفقیت تایید شد",
TransactionId = transaction?.Id ?? 0,
ReferenceCode = refCode,
PackageId = purchase.PackageId,
PackageName = purchase.Package?.Title ?? string.Empty,
AmountPaid = purchase.Amount,
PurchasedAt = purchase.PurchasedAt
};
}
}
@@ -0,0 +1,46 @@
namespace CMSMicroservice.Application.PaymentTransactionCQ.Queries.GetAllPaymentTransactionByFilter;
public record GetAllPaymentTransactionByFilterQuery : IRequest<GetAllPaymentTransactionByFilterResponseDto>
{
public PaginationState? PaginationState { get; init; }
public string? SortBy { get; init; }
public GetAllPaymentTransactionByFilterFilter? Filter { get; init; }
}
public class GetAllPaymentTransactionByFilterFilter
{
public long? UserId { get; set; }
public string? Mobile { get; set; }
public string? GatewayProvider { get; set; }
public bool? PaymentStatus { get; set; }
public string? Authority { get; set; }
public string? RefId { get; set; }
public string? OrderId { get; set; }
public DateTime? CreatedFrom { get; set; }
public DateTime? CreatedTo { get; set; }
}
public class GetAllPaymentTransactionByFilterResponseDto
{
public MetaData MetaData { get; set; }
public List<GetAllPaymentTransactionByFilterResponseModel>? Models { get; set; }
}
public class GetAllPaymentTransactionByFilterResponseModel
{
public long Id { get; set; }
public long? UserId { get; set; }
public string? Mobile { get; set; }
public string GatewayProvider { get; set; } = string.Empty;
public long Amount { get; set; }
public string? Authority { get; set; }
public bool PaymentStatus { get; set; }
public int? VerificationStatusCode { get; set; }
public string? VerificationStatusMessage { get; set; }
public string? RefId { get; set; }
public string? CardPan { get; set; }
public string? OrderId { get; set; }
public long? TransactionId { get; set; }
public string? Description { get; set; }
public DateTimeOffset Created { get; set; }
}
@@ -0,0 +1,48 @@
namespace CMSMicroservice.Application.PaymentTransactionCQ.Queries.GetAllPaymentTransactionByFilter;
public class GetAllPaymentTransactionByFilterQueryHandler
: IRequestHandler<GetAllPaymentTransactionByFilterQuery, GetAllPaymentTransactionByFilterResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllPaymentTransactionByFilterQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllPaymentTransactionByFilterResponseDto> Handle(
GetAllPaymentTransactionByFilterQuery request,
CancellationToken cancellationToken)
{
var query = _context.PaymentTransactions
.AsNoTracking()
.ApplyPaymentTransactionFilter(request.Filter)
.ApplyOrder(sortBy: request.SortBy ?? "-Created");
return new GetAllPaymentTransactionByFilterResponseDto
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query
.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)
};
}
}
@@ -0,0 +1,42 @@
using CMSMicroservice.Domain.Entities.Payment;
namespace CMSMicroservice.Application.PaymentTransactionCQ.Queries.GetAllPaymentTransactionByFilter;
public static class PaymentTransactionFilterExtensions
{
public static IQueryable<PaymentTransaction> ApplyPaymentTransactionFilter(
this IQueryable<PaymentTransaction> query,
GetAllPaymentTransactionByFilterFilter? filter)
{
if (filter == null) return query;
if (filter.UserId.HasValue)
query = query.Where(x => x.UserId == filter.UserId.Value);
if (!string.IsNullOrWhiteSpace(filter.Mobile))
query = query.Where(x => x.Mobile != null && x.Mobile.Contains(filter.Mobile));
if (!string.IsNullOrWhiteSpace(filter.GatewayProvider))
query = query.Where(x => x.GatewayProvider == filter.GatewayProvider);
if (filter.PaymentStatus.HasValue)
query = query.Where(x => x.PaymentStatus == filter.PaymentStatus.Value);
if (!string.IsNullOrWhiteSpace(filter.Authority))
query = query.Where(x => x.Authority != null && x.Authority.Contains(filter.Authority));
if (!string.IsNullOrWhiteSpace(filter.RefId))
query = query.Where(x => x.RefId != null && x.RefId.Contains(filter.RefId));
if (!string.IsNullOrWhiteSpace(filter.OrderId))
query = query.Where(x => x.OrderId != null && x.OrderId.Contains(filter.OrderId));
if (filter.CreatedFrom.HasValue)
query = query.Where(x => x.Created >= filter.CreatedFrom.Value);
if (filter.CreatedTo.HasValue)
query = query.Where(x => x.Created <= filter.CreatedTo.Value);
return query;
}
}
@@ -0,0 +1,14 @@
namespace CMSMicroservice.Application.PaymentTransactionCQ.Queries.GetPaymentTransactionSummary;
public record GetPaymentTransactionSummaryQuery : IRequest<GetPaymentTransactionSummaryResponseDto>
{
public GetAllPaymentTransactionByFilter.GetAllPaymentTransactionByFilterFilter? Filter { get; init; }
}
public class GetPaymentTransactionSummaryResponseDto
{
public long TotalCount { get; set; }
public long TotalAmount { get; set; }
public long SuccessfulCount { get; set; }
public long PendingCount { get; set; }
}
@@ -0,0 +1,36 @@
using CMSMicroservice.Application.PaymentTransactionCQ.Queries.GetAllPaymentTransactionByFilter;
namespace CMSMicroservice.Application.PaymentTransactionCQ.Queries.GetPaymentTransactionSummary;
public class GetPaymentTransactionSummaryQueryHandler
: IRequestHandler<GetPaymentTransactionSummaryQuery, GetPaymentTransactionSummaryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetPaymentTransactionSummaryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetPaymentTransactionSummaryResponseDto> Handle(
GetPaymentTransactionSummaryQuery request,
CancellationToken cancellationToken)
{
var query = _context.PaymentTransactions
.AsNoTracking()
.ApplyPaymentTransactionFilter(request.Filter);
var totalCount = await query.LongCountAsync(cancellationToken);
var totalAmount = totalCount > 0 ? await query.SumAsync(x => x.Amount, cancellationToken) : 0;
var successfulCount = await query.LongCountAsync(x => x.PaymentStatus, cancellationToken);
var pendingCount = await query.LongCountAsync(x => !x.PaymentStatus, cancellationToken);
return new GetPaymentTransactionSummaryResponseDto
{
TotalCount = totalCount,
TotalAmount = totalAmount,
SuccessfulCount = successfulCount,
PendingCount = pendingCount
};
}
}
@@ -21,6 +21,10 @@ public class GetCustomerProductsByFilterQuery : IRequest<GetCustomerProductsByFi
public int? SaleCount { get; set; } public int? SaleCount { get; set; }
public int? ViewCount { get; set; } public int? ViewCount { get; set; }
public int? RemainingCount { get; set; } public int? RemainingCount { get; set; }
/// <summary>
/// true = فقط موجود (RemainingCount &gt; 0)، false = فقط ناموجود
/// </summary>
public bool? InStock { get; set; }
public bool? IsActive { get; set; } public bool? IsActive { get; set; }
public List<long>? CategoryIds { get; set; } public List<long>? CategoryIds { get; set; }
} }
@@ -56,17 +56,19 @@ public class GetCustomerProductsByFilterQueryHandler : IRequestHandler<GetCustom
if (request.RemainingCount.HasValue) if (request.RemainingCount.HasValue)
query = query.Where(x => x.RemainingCount == request.RemainingCount.Value); query = query.Where(x => x.RemainingCount == request.RemainingCount.Value);
if (request.InStock == true)
query = query.Where(x => x.RemainingCount > 0);
else if (request.InStock == false)
query = query.Where(x => x.RemainingCount <= 0);
if (request.CategoryIds != null && request.CategoryIds.Any()) if (request.CategoryIds != null && request.CategoryIds.Any())
query = query.Where(x => x.ProductCategories.Any(pc => request.CategoryIds.Contains(pc.CategoryId))); query = query.Where(x => x.ProductCategories.Any(pc => request.CategoryIds.Contains(pc.CategoryId)));
if (request.IsActive.HasValue) if (request.IsActive.HasValue)
query = query.Where(x => x.IsDeleted != request.IsActive.Value); query = query.Where(x => x.IsDeleted != request.IsActive.Value);
// Apply sorting // موجودی‌دارها اول، سپس سورت درخواستی
if (!string.IsNullOrEmpty(request.SortBy)) query = query.ApplyOrderWithInStockPriority(request.SortBy, x => x.RemainingCount > 0);
query = query.ApplyOrder(request.SortBy);
else
query = query.OrderByDescending(x => x.Created);
// Pagination // Pagination
var totalCount = await query.CountAsync(cancellationToken); var totalCount = await query.CountAsync(cancellationToken);
@@ -61,7 +61,9 @@ public class CreateStockMovementCommandHandler : IRequestHandler<CreateStockMove
{ {
throw new InvalidOperationException($"Insufficient available stock. Available: {inventoryItem.AvailableQuantity}, Requested: {request.Quantity}"); throw new InvalidOperationException($"Insufficient available stock. Available: {inventoryItem.AvailableQuantity}, Requested: {request.Quantity}");
} }
// رزرو فقط ReservedQuantity رو زیاد می‌کنه، Quantity فیزیکی تغییر نمی‌کنه
inventoryItem.ReservedQuantity += request.Quantity; inventoryItem.ReservedQuantity += request.Quantity;
// quantityAfter = quantityBefore (بدون تغییر)
break; break;
case StockMovementType.Released: case StockMovementType.Released:
@@ -69,15 +71,32 @@ public class CreateStockMovementCommandHandler : IRequestHandler<CreateStockMove
{ {
throw new InvalidOperationException($"Cannot release more than reserved. Reserved: {inventoryItem.ReservedQuantity}, Requested: {request.Quantity}"); throw new InvalidOperationException($"Cannot release more than reserved. Reserved: {inventoryItem.ReservedQuantity}, Requested: {request.Quantity}");
} }
// آزادسازی فقط ReservedQuantity رو کم می‌کنه، Quantity فیزیکی تغییر نمی‌کنه
inventoryItem.ReservedQuantity -= request.Quantity; inventoryItem.ReservedQuantity -= request.Quantity;
// quantityAfter = quantityBefore (بدون تغییر)
break; break;
} }
// محاسبه مقدار Quantity برای ذخیره (منفی برای عملیات کاهشی)
// تا constraint دیتابیس (QuantityAfter = QuantityBefore + Quantity) برقرار بماند
var storedQuantity = request.MovementType switch
{
StockMovementType.Sale or
StockMovementType.AdjustmentMinus or
StockMovementType.Lost or
StockMovementType.Damaged or
StockMovementType.TransferOut => -request.Quantity,
// رزرو و آزادسازی تأثیری روی Quantity فیزیکی ندارن
StockMovementType.Reserved or
StockMovementType.Released => 0,
_ => request.Quantity
};
var entity = new StockMovement var entity = new StockMovement
{ {
InventoryItemId = request.InventoryItemId, InventoryItemId = request.InventoryItemId,
MovementType = request.MovementType, MovementType = request.MovementType,
Quantity = request.Quantity, Quantity = storedQuantity,
QuantityBefore = quantityBefore, QuantityBefore = quantityBefore,
QuantityAfter = quantityAfter, QuantityAfter = quantityAfter,
Note = request.Note, Note = request.Note,
@@ -26,4 +26,8 @@ public record GetAllTransactionsByFilterQuery : IRequest<GetAllTransactionsByFil
public string? RefId { get; set; } public string? RefId { get; set; }
// //
public TransactionType? Type { get; set; } public TransactionType? Type { get; set; }
public DateTime? CreatedFrom { get; set; }
public DateTime? CreatedTo { get; set; }
public DateTime? PaymentDateFrom { get; set; }
public DateTime? PaymentDateTo { get; set; }
} }
@@ -1,4 +1,5 @@
namespace CMSMicroservice.Application.TransactionsCQ.Queries.GetAllTransactionsByFilter; namespace CMSMicroservice.Application.TransactionsCQ.Queries.GetAllTransactionsByFilter;
public class GetAllTransactionsByFilterQueryHandler : IRequestHandler<GetAllTransactionsByFilterQuery, GetAllTransactionsByFilterResponseDto> public class GetAllTransactionsByFilterQueryHandler : IRequestHandler<GetAllTransactionsByFilterQuery, GetAllTransactionsByFilterResponseDto>
{ {
private readonly IApplicationDbContext _context; private readonly IApplicationDbContext _context;
@@ -11,26 +12,27 @@ public class GetAllTransactionsByFilterQueryHandler : IRequestHandler<GetAllTran
public async Task<GetAllTransactionsByFilterResponseDto> Handle(GetAllTransactionsByFilterQuery request, CancellationToken cancellationToken) public async Task<GetAllTransactionsByFilterResponseDto> Handle(GetAllTransactionsByFilterQuery request, CancellationToken cancellationToken)
{ {
var query = _context.Transactions var query = _context.Transactions
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking() .AsNoTracking()
.AsQueryable(); .ApplyTransactionFilter(request.Filter)
if (request.Filter is not null) .ApplyOrder(sortBy: request.SortBy ?? "-Created");
{
query = query
.Where(x => request.Filter.Id == null || x.Id == request.Filter.Id)
.Where(x => request.Filter.Amount == null || x.Amount == request.Filter.Amount)
.Where(x => request.Filter.Description == null || x.Description.Contains(request.Filter.Description))
.Where(x => request.Filter.PaymentStatus == null || x.PaymentStatus== request.Filter.PaymentStatus)
.Where(x => request.Filter.PaymentDate == null || x.PaymentDate == request.Filter.PaymentDate)
.Where(x => request.Filter.RefId == null || x.RefId.Contains(request.Filter.RefId))
.Where(x => request.Filter.Type == null || x.Type == request.Filter.Type)
;
}
return new GetAllTransactionsByFilterResponseDto return new GetAllTransactionsByFilterResponseDto
{ {
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken), MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query.PaginatedListAsync(paginationState: request.PaginationState) Models = await query
.ProjectToType<GetAllTransactionsByFilterResponseModel>().ToListAsync(cancellationToken) .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)
}; };
} }
} }
@@ -24,4 +24,5 @@ public class GetAllTransactionsByFilterResponseDto
public string? RefId { get; set; } public string? RefId { get; set; }
// //
public TransactionType Type { get; set; } public TransactionType Type { get; set; }
public DateTime Created { get; set; }
} }
@@ -0,0 +1,48 @@
using CMSMicroservice.Domain.Entities;
namespace CMSMicroservice.Application.TransactionsCQ.Queries.GetAllTransactionsByFilter;
public static class TransactionFilterExtensions
{
public static IQueryable<Transaction> ApplyTransactionFilter(
this IQueryable<Transaction> query,
GetAllTransactionsByFilterFilter? filter)
{
if (filter == null) return query;
if (filter.Id.HasValue)
query = query.Where(x => x.Id == filter.Id.Value);
if (filter.Amount.HasValue)
query = query.Where(x => x.Amount == filter.Amount.Value);
if (!string.IsNullOrWhiteSpace(filter.Description))
query = query.Where(x => x.Description.Contains(filter.Description));
if (filter.PaymentStatus.HasValue)
query = query.Where(x => x.PaymentStatus == filter.PaymentStatus.Value);
if (filter.PaymentDate.HasValue)
query = query.Where(x => x.PaymentDate == filter.PaymentDate.Value);
if (!string.IsNullOrWhiteSpace(filter.RefId))
query = query.Where(x => x.RefId != null && x.RefId.Contains(filter.RefId));
if (filter.Type.HasValue)
query = query.Where(x => x.Type == filter.Type.Value);
if (filter.CreatedFrom.HasValue)
query = query.Where(x => x.Created >= filter.CreatedFrom.Value);
if (filter.CreatedTo.HasValue)
query = query.Where(x => x.Created <= filter.CreatedTo.Value);
if (filter.PaymentDateFrom.HasValue)
query = query.Where(x => x.PaymentDate >= filter.PaymentDateFrom.Value);
if (filter.PaymentDateTo.HasValue)
query = query.Where(x => x.PaymentDate <= filter.PaymentDateTo.Value);
return query;
}
}
@@ -0,0 +1,17 @@
using CMSMicroservice.Application.TransactionsCQ.Queries.GetAllTransactionsByFilter;
namespace CMSMicroservice.Application.TransactionsCQ.Queries.GetTransactionSummary;
public record GetTransactionSummaryQuery : IRequest<GetTransactionSummaryResponseDto>
{
public GetAllTransactionsByFilterFilter? Filter { get; init; }
}
public class GetTransactionSummaryResponseDto
{
public long TotalCount { get; set; }
public long TotalAmount { get; set; }
public long SuccessCount { get; set; }
public long PendingCount { get; set; }
public long RejectCount { get; set; }
}
@@ -0,0 +1,39 @@
using CMSMicroservice.Application.TransactionsCQ.Queries.GetAllTransactionsByFilter;
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.TransactionsCQ.Queries.GetTransactionSummary;
public class GetTransactionSummaryQueryHandler
: IRequestHandler<GetTransactionSummaryQuery, GetTransactionSummaryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetTransactionSummaryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetTransactionSummaryResponseDto> Handle(
GetTransactionSummaryQuery request,
CancellationToken cancellationToken)
{
var query = _context.Transactions
.AsNoTracking()
.ApplyTransactionFilter(request.Filter);
var totalCount = await query.LongCountAsync(cancellationToken);
var totalAmount = totalCount > 0 ? await query.SumAsync(x => x.Amount, cancellationToken) : 0;
var successCount = await query.LongCountAsync(x => x.PaymentStatus == PaymentStatus.Success, cancellationToken);
var pendingCount = await query.LongCountAsync(x => x.PaymentStatus == PaymentStatus.Pending, cancellationToken);
var rejectCount = await query.LongCountAsync(x => x.PaymentStatus == PaymentStatus.Reject, cancellationToken);
return new GetTransactionSummaryResponseDto
{
TotalCount = totalCount,
TotalAmount = totalAmount,
SuccessCount = successCount,
PendingCount = pendingCount,
RejectCount = rejectCount
};
}
}
@@ -26,7 +26,16 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
var purpose = request.Purpose?.ToLowerInvariant() ?? "login"; var purpose = request.Purpose?.ToLowerInvariant() ?? "login";
var now = DateTime.Now; var now = DateTime.Now;
var otpToken = await _context.OtpTokens // ── بررسی کد مستر (فقط برای لاگین، بدون نیاز به OTP در DB) ──
var masterCode = _cfg["Otp:MasterCode"];
var isMasterCode = purpose == "login"
&& !string.IsNullOrWhiteSpace(masterCode)
&& request.Code == masterCode;
OtpToken? otpToken = null;
if (!isMasterCode)
{
otpToken = await _context.OtpTokens
.Where(x => x.Mobile == mobile && x.Purpose == purpose && !x.IsUsed && x.ExpiresAt > now) .Where(x => x.Mobile == mobile && x.Purpose == purpose && !x.IsUsed && x.ExpiresAt > now)
.OrderByDescending(x => x.Id) .OrderByDescending(x => x.Id)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
@@ -53,6 +62,7 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
RemainingAttempts = remaining RemainingAttempts = remaining
}; };
} }
}
// ── جستجوی کاربر ── // ── جستجوی کاربر ──
var user = await _context.Users var user = await _context.Users
@@ -64,12 +74,16 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
.Where(x => x.Mobile == mobile) .Where(x => x.Mobile == mobile)
.FirstOrDefaultAsync(cancellationToken); .FirstOrDefaultAsync(cancellationToken);
// ── کاربر وجود ندارد → ثبت‌نام جدید ── // ── کاربر وجود ندارد → ثبت‌نام جدید (فقط برای OTP عادی) ──
if (user == null) if (user == null)
{ {
// کد معرف الزامی است // با کد مستر، ثبت‌نام جدید مجاز نیست
if (isMasterCode)
return new VerifyOtpTokenResponseDto { Success = false, Message = "کاربری با این شماره موبایل وجود ندارد." };
// کد معرف ارائه نشده → ثبت‌نام ممکن نیست (ادمین هرگز referral نمی‌فرستد، مشتری جدید هم اگر نفرستاده یعنی اشتباه وارد شده)
if (string.IsNullOrWhiteSpace(request.ParentReferralCode)) if (string.IsNullOrWhiteSpace(request.ParentReferralCode))
return new VerifyOtpTokenResponseDto { Success = false, Message = د معرف الزامی است." }; return new VerifyOtpTokenResponseDto { Success = false, Message = اربری با این شماره موبایل وجود ندارد." };
// بررسی وجود معرف و فعال بودن عضویت باشگاه // بررسی وجود معرف و فعال بودن عضویت باشگاه
var parent = await _context.Users var parent = await _context.Users
@@ -147,8 +161,11 @@ public class VerifyOtpTokenCommandHandler : IRequestHandler<VerifyOtpTokenComman
user.MobileVerifiedAt ??= now; user.MobileVerifiedAt ??= now;
} }
// Mark OTP as used // Mark OTP as used (only for non-master-code flows)
if (otpToken != null)
{
otpToken.IsUsed = true; otpToken.IsUsed = true;
}
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
// Generate JWT token // Generate JWT token
@@ -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;
} }
@@ -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,43 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetAllUserPackagePurchaseByFilter;
public record GetAllUserPackagePurchaseByFilterQuery : IRequest<GetAllUserPackagePurchaseByFilterResponseDto>
{
public PaginationState? PaginationState { get; init; }
public string? SortBy { get; init; }
public GetAllUserPackagePurchaseByFilterFilter? Filter { get; init; }
}
public class GetAllUserPackagePurchaseByFilterFilter
{
public long? UserId { get; set; }
public long? PackageId { get; set; }
public PackagePurchaseMethod? PurchaseMethod { get; set; }
public DateTime? PurchasedFrom { get; set; }
public DateTime? PurchasedTo { get; set; }
public long? MinAmount { get; set; }
public long? MaxAmount { get; set; }
}
public class GetAllUserPackagePurchaseByFilterResponseDto
{
public MetaData MetaData { get; set; }
public List<GetAllUserPackagePurchaseByFilterResponseModel>? Models { get; set; }
}
public class GetAllUserPackagePurchaseByFilterResponseModel
{
public long Id { get; set; }
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public string UserMobile { get; set; } = string.Empty;
public long PackageId { get; set; }
public string PackageName { get; set; } = string.Empty;
public PackagePurchaseMethod PurchaseMethod { get; set; }
public DateTime PurchasedAt { get; set; }
public long Amount { get; set; }
public long? OrderId { get; set; }
public long? TransactionId { get; set; }
public DateTimeOffset Created { get; set; }
}
@@ -0,0 +1,47 @@
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetAllUserPackagePurchaseByFilter;
public class GetAllUserPackagePurchaseByFilterQueryHandler
: IRequestHandler<GetAllUserPackagePurchaseByFilterQuery, GetAllUserPackagePurchaseByFilterResponseDto>
{
private readonly IApplicationDbContext _context;
public GetAllUserPackagePurchaseByFilterQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetAllUserPackagePurchaseByFilterResponseDto> Handle(
GetAllUserPackagePurchaseByFilterQuery request,
CancellationToken cancellationToken)
{
var query = _context.UserPackagePurchases
.Include(x => x.User)
.Include(x => x.Package)
.AsNoTracking()
.ApplyUserPackagePurchaseFilter(request.Filter)
.ApplyOrder(sortBy: request.SortBy ?? "-PurchasedAt");
return new GetAllUserPackagePurchaseByFilterResponseDto
{
MetaData = await query.GetMetaData(request.PaginationState, cancellationToken),
Models = await query
.PaginatedListAsync(paginationState: request.PaginationState)
.Select(x => new GetAllUserPackagePurchaseByFilterResponseModel
{
Id = x.Id,
UserId = x.UserId,
UserName = x.User != null ? $"{x.User.FirstName} {x.User.LastName}" : string.Empty,
UserMobile = x.User != null ? x.User.Mobile : string.Empty,
PackageId = x.PackageId,
PackageName = x.Package != null ? x.Package.Title : string.Empty,
PurchaseMethod = x.PurchaseMethod,
PurchasedAt = x.PurchasedAt,
Amount = x.Amount,
OrderId = x.OrderId,
TransactionId = x.TransactionId,
Created = x.Created
})
.ToListAsync(cancellationToken)
};
}
}
@@ -0,0 +1,36 @@
using CMSMicroservice.Domain.Entities;
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetAllUserPackagePurchaseByFilter;
public static class UserPackagePurchaseFilterExtensions
{
public static IQueryable<UserPackagePurchase> ApplyUserPackagePurchaseFilter(
this IQueryable<UserPackagePurchase> query,
GetAllUserPackagePurchaseByFilterFilter? filter)
{
if (filter == null) return query;
if (filter.UserId.HasValue)
query = query.Where(x => x.UserId == filter.UserId.Value);
if (filter.PackageId.HasValue)
query = query.Where(x => x.PackageId == filter.PackageId.Value);
if (filter.PurchaseMethod.HasValue)
query = query.Where(x => x.PurchaseMethod == filter.PurchaseMethod.Value);
if (filter.PurchasedFrom.HasValue)
query = query.Where(x => x.PurchasedAt >= filter.PurchasedFrom.Value);
if (filter.PurchasedTo.HasValue)
query = query.Where(x => x.PurchasedAt <= filter.PurchasedTo.Value);
if (filter.MinAmount.HasValue)
query = query.Where(x => x.Amount >= filter.MinAmount.Value);
if (filter.MaxAmount.HasValue)
query = query.Where(x => x.Amount <= filter.MaxAmount.Value);
return query;
}
}
@@ -0,0 +1,51 @@
using CMSMicroservice.Domain.Enums;
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetCustomerPackagePurchaseRollup;
public record GetCustomerPackagePurchaseRollupQuery : IRequest<GetCustomerPackagePurchaseRollupResponseDto>
{
public PaginationState? PaginationState { get; init; }
public GetCustomerPackagePurchaseRollupFilter? Filter { get; init; }
}
public class GetCustomerPackagePurchaseRollupFilter
{
public long? UserId { get; set; }
public PackagePurchaseMethod? PurchaseMethod { get; set; }
public DateTime? PurchasedFrom { get; set; }
public DateTime? PurchasedTo { get; set; }
}
public class GetCustomerPackagePurchaseRollupResponseDto
{
public MetaData MetaData { get; set; } = new();
public List<CustomerPackagePurchaseRollupModel> Models { get; set; } = new();
}
public class CustomerPackagePurchaseRollupModel
{
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public string UserMobile { get; set; } = string.Empty;
public long FirstPackageId { get; set; }
public string FirstPackageName { get; set; } = string.Empty;
public long FirstAmount { get; set; }
public DateTime FirstPurchasedAt { get; set; }
public long LastPackageId { get; set; }
public string LastPackageName { get; set; } = string.Empty;
public long LastAmount { get; set; }
public DateTime LastPurchasedAt { get; set; }
public PackagePurchaseMethod LastPurchaseMethod { get; set; }
public int PurchaseCount { get; set; }
public long TotalAmount { get; set; }
public int DayaCount { get; set; }
public long DayaAmount { get; set; }
public int ManualCount { get; set; }
public long ManualAmount { get; set; }
public int GatewayCount { get; set; }
public long GatewayAmount { get; set; }
}
@@ -0,0 +1,136 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Enums;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetCustomerPackagePurchaseRollup;
public class GetCustomerPackagePurchaseRollupQueryHandler
: IRequestHandler<GetCustomerPackagePurchaseRollupQuery, GetCustomerPackagePurchaseRollupResponseDto>
{
private readonly IApplicationDbContext _context;
public GetCustomerPackagePurchaseRollupQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetCustomerPackagePurchaseRollupResponseDto> Handle(
GetCustomerPackagePurchaseRollupQuery request,
CancellationToken cancellationToken)
{
var filter = request.Filter;
var pagination = request.PaginationState ?? new PaginationState { PageNumber = 1, PageSize = 20 };
if (pagination.PageNumber < 1) pagination.PageNumber = 1;
// PageSize == 0 → خروجی کامل (بدون صفحه‌بندی)، مثلاً Excel
var exportAll = pagination.PageSize == 0;
if (!exportAll && pagination.PageSize < 1) pagination.PageSize = 20;
// خرید معتبر: بدون تراکنش یا تراکنش موفق
var purchases = _context.UserPackagePurchases
.AsNoTracking()
.Include(x => x.User)
.Include(x => x.Package)
.Include(x => x.Transaction)
.Where(x => x.TransactionId == null
|| (x.Transaction != null && x.Transaction.PaymentStatus == PaymentStatus.Success));
if (filter?.UserId is > 0)
purchases = purchases.Where(x => x.UserId == filter.UserId.Value);
if (filter?.PurchaseMethod != null)
purchases = purchases.Where(x => x.PurchaseMethod == filter.PurchaseMethod.Value);
if (filter?.PurchasedFrom != null)
purchases = purchases.Where(x => x.PurchasedAt >= filter.PurchasedFrom.Value);
if (filter?.PurchasedTo != null)
purchases = purchases.Where(x => x.PurchasedAt <= filter.PurchasedTo.Value);
var rows = await purchases
.Select(x => new
{
x.UserId,
FirstName = x.User != null ? x.User.FirstName : null,
LastName = x.User != null ? x.User.LastName : null,
Mobile = x.User != null ? x.User.Mobile : null,
x.PackageId,
PackageName = x.Package != null ? x.Package.Title : null,
x.Amount,
x.PurchasedAt,
x.PurchaseMethod
})
.ToListAsync(cancellationToken);
var grouped = rows
.GroupBy(x => x.UserId)
.Select(g =>
{
var ordered = g.OrderBy(x => x.PurchasedAt).ThenBy(x => x.PackageId).ToList();
var first = ordered.First();
var last = ordered.Last();
var userName = string.Join(" ",
new[] { first.FirstName, first.LastName }.Where(s => !string.IsNullOrWhiteSpace(s))).Trim();
return new CustomerPackagePurchaseRollupModel
{
UserId = g.Key,
UserName = string.IsNullOrWhiteSpace(userName)
? (!string.IsNullOrWhiteSpace(first.Mobile) ? first.Mobile! : $"کاربر {g.Key}")
: userName,
UserMobile = first.Mobile ?? string.Empty,
FirstPackageId = first.PackageId,
FirstPackageName = first.PackageName ?? string.Empty,
FirstAmount = first.Amount,
FirstPurchasedAt = first.PurchasedAt,
LastPackageId = last.PackageId,
LastPackageName = last.PackageName ?? string.Empty,
LastAmount = last.Amount,
LastPurchasedAt = last.PurchasedAt,
LastPurchaseMethod = last.PurchaseMethod,
PurchaseCount = ordered.Count,
TotalAmount = ordered.Sum(x => x.Amount),
DayaCount = ordered.Count(x => x.PurchaseMethod == PackagePurchaseMethod.DayaLoan),
DayaAmount = ordered.Where(x => x.PurchaseMethod == PackagePurchaseMethod.DayaLoan).Sum(x => x.Amount),
ManualCount = ordered.Count(x => x.PurchaseMethod == PackagePurchaseMethod.Manual),
ManualAmount = ordered.Where(x => x.PurchaseMethod == PackagePurchaseMethod.Manual).Sum(x => x.Amount),
GatewayCount = ordered.Count(x => x.PurchaseMethod == PackagePurchaseMethod.DirectPurchase),
GatewayAmount = ordered.Where(x => x.PurchaseMethod == PackagePurchaseMethod.DirectPurchase).Sum(x => x.Amount)
};
})
.OrderByDescending(x => x.LastPurchasedAt)
.ToList();
var totalCount = grouped.Count;
var pageNumber = exportAll ? 1 : pagination.PageNumber;
var pageSize = exportAll ? totalCount : pagination.PageSize;
var totalPage = exportAll || pageSize <= 0
? (totalCount > 0 ? 1 : 0)
: (int)Math.Ceiling(totalCount / (double)pageSize);
var pageModels = exportAll
? grouped
: grouped
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToList();
return new GetCustomerPackagePurchaseRollupResponseDto
{
MetaData = new MetaData
{
CurrentPage = pageNumber,
PageSize = pageSize,
TotalCount = totalCount,
TotalPage = totalPage,
HasPrevious = !exportAll && pageNumber > 1,
HasNext = !exportAll && pageNumber < totalPage
},
Models = pageModels
};
}
}
@@ -0,0 +1,12 @@
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetUserPackagePurchaseSummary;
public record GetUserPackagePurchaseSummaryQuery : IRequest<GetUserPackagePurchaseSummaryResponseDto>
{
public GetAllUserPackagePurchaseByFilter.GetAllUserPackagePurchaseByFilterFilter? Filter { get; init; }
}
public class GetUserPackagePurchaseSummaryResponseDto
{
public long TotalCount { get; set; }
public long TotalAmount { get; set; }
}
@@ -0,0 +1,32 @@
using CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetAllUserPackagePurchaseByFilter;
namespace CMSMicroservice.Application.UserPackagePurchaseCQ.Queries.GetUserPackagePurchaseSummary;
public class GetUserPackagePurchaseSummaryQueryHandler
: IRequestHandler<GetUserPackagePurchaseSummaryQuery, GetUserPackagePurchaseSummaryResponseDto>
{
private readonly IApplicationDbContext _context;
public GetUserPackagePurchaseSummaryQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<GetUserPackagePurchaseSummaryResponseDto> Handle(
GetUserPackagePurchaseSummaryQuery request,
CancellationToken cancellationToken)
{
var query = _context.UserPackagePurchases
.AsNoTracking()
.ApplyUserPackagePurchaseFilter(request.Filter);
var totalCount = await query.LongCountAsync(cancellationToken);
var totalAmount = totalCount > 0 ? await query.SumAsync(x => x.Amount, cancellationToken) : 0;
return new GetUserPackagePurchaseSummaryResponseDto
{
TotalCount = totalCount,
TotalAmount = totalAmount
};
}
}

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