134 Commits

Author SHA1 Message Date
masoodafar-web ea1d2d4400 feat(network): enrich network tree with package leg scores and update Protobuf definitions
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 12m47s
- Added logic to enrich the network tree with the latest left/right leg totals for gold and silver packages in GetNetworkTreeQueryHandler.
- Introduced new properties in NetworkTreeDto for GoldLeftLegTotal, GoldRightLegTotal, SilverLeftLegTotal, and SilverRightLegTotal.
- Updated Protobuf definitions to include new fields for leg scores in NetworkTreeNodeModel.
- Enhanced mapping in NetworkMembershipProfile and NetworkMembershipService to accommodate new leg score properties.
- Bumped Protobuf project version to reflect the addition of new features.
2026-08-25 22:39:19 +03:30
masoodafar-web 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
masoodafar-web 1a0bb83c99 feat: Enhance SMS verification with new token handling and update API key
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m45s
2026-02-28 04:01:45 +03:30
masoodafar-web 1e7c17f090 feat: Update payment processing and callback mechanisms
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m22s
- Refactor ActivateClubMembershipCommandHandler to use UserPackagePurchases instead of UserOrders for package activation.
- Modify PlaceOrderCommandHandler to redirect payment callbacks to the front office.
- Update ChargeDiscountWalletCommandHandler and ChargeMagicWalletCommandHandler to direct payment callbacks to the front office.
- Remove PaymentCallbackController and integrate payment verification directly into DiscountOrderService and UserWalletService.
- Add CustomerVerifyDiscountOrderPayment RPC to DiscountOrderService for verifying discount order payments.
- Implement VerifyMagicCharge and VerifyDiscountCharge methods in UserWalletService for wallet charge verifications.
- Update appsettings.json to use local URLs for development.
- Remove appsettings.Development.json as it is no longer needed.
- Comment out history tracking methods in ClubMembershipCycle and Package classes.
- Update PackageService to automatically activate club membership after successful payment verification.
- Adjust UserService to generate JWT tokens with user details.
2026-02-28 03:27:46 +03:30
masoodafar-web 01073084ab security: remove callback URL from user input — read from FrontOfficeBaseUrl config
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m28s
- PackageService: build callback URL from FrontOfficeBaseUrl config instead of request.CallbackUrl
- TransactionsService: same fix — callback URL from config
- Prevents potential open redirect vulnerability (user could send arbitrary URL)

All callback URLs now come from appsettings FrontOfficeBaseUrl or CmsBaseUrl.
2026-02-27 22:20:04 +03:30
masoodafar-web ed2b20a98e fix: wallet callback URL issues
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- PaymentCallbackController: fix redirect paths /magic-wallet → /profile/magic-wallet
- Add appsettings.Development.json with local CmsBaseUrl (localhost:32846) and FrontOfficeBaseUrl (localhost:5268)
- Proto: fix comments ریال → تومان in wallet charge messages

Root cause: CmsBaseUrl in appsettings.json points to stage (cms.se.kbs1.ir),
so when testing locally, ZarinPal callback went to stage CMS instead of local.
Also redirect after verify used wrong FO path (/magic-wallet instead of /profile/magic-wallet).
2026-02-27 22:09:15 +03:30
masoodafar-web 721661af0f fix: pass amount to ZarinPal VerifyPaymentAsync — fix verify failure (amount=0 bug)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m38s
- PackageService.CustomerVerifyPackagePurchase: look up PaymentTransaction.Amount and pass to 3-arg overload
- TransactionsService.CustomerPaymentVerification: same fix
- VerifyDiscountWalletChargeCommandHandler: look up amount from PaymentTransaction
- VerifyPackagePurchaseCommandHandler: fix copy-paste bug (Authority as verificationToken) + add amount
- IPaymentGatewayService: throw NotImplementedException in default 3-arg impl to prevent silent amount=0
- MockPaymentGatewayService & DayaPaymentService: add 3-arg overload for compatibility

Root cause: ZarinPal requires the exact amount in verify request.
The 2-arg overload was sending amount=0 which caused Code=-1 (تأیید تراکنش ناموفق).
2026-02-27 21:17:50 +03:30
masoodafar-web aee7b59fc1 fix: Toman/Rial — system uses Toman everywhere, only ZarinPal gets Rial
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m16s
Core change in ZarinPalPaymentService:
- InitiatePaymentAsync: Amount (Toman) × 10 → Rial for gateway
- VerifyPaymentWithAmountAsync: Amount (Toman) × 10 → Rial for verify
- Verify result: gateway Rial ÷ 10 → Toman back to system

Also fixed:
- Package.Price doc: ریال → تومان
- Error messages in 3 handlers: ریال → تومان
- ConfigurationService descriptions: ریال → تومان (7 entries)
- Notification SMS/email: ریال → تومان
- Doc comments in 5 command files: ریال → تومان
- PaymentCallback comments: ریال → تومان

14 files changed across Domain, Application, Infrastructure, WebApi
2026-02-27 21:03:27 +03:30
masoodafar-web 61b7e4f8f2 F2-F7: نوتیفیکیشن‌ها+نام‌پکیج، ستون پکیج در CSV، مقادیر داینامیک کیف‌پول جادویی، ولیدیتور SystemConstants
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m29s
F2: اضافه شدن packageName به SmsTemplates، IUserNotificationService، UserNotificationService
    - DayaLoan، ClubActivated، CommissionDeposited حالا نام پکیج را نشان می‌دهند
F4: اضافه شدن ستون پکیج به CSV خروجی‌ها
    - commission.proto: package_name در WithdrawalRequestModel
    - manualpayment.proto: package_name در ManualPaymentModel
    - کوئری‌هندلرها UserPackagePurchases لوکاپ اضافه شد
F6: مقادیر داینامیک کیف‌پول جادویی از پکیج
    - userwallet.proto: magic_multiplier + magic_max_credit
    - UserWalletService: پاپیولیت فیلدهای جدید + فالبک به SystemConstants
F7: ولیدیتورها از SystemConstants استفاده می‌کنند
    - WalletMaxSafeAmount (10B ریال) به عنوان حصار ایمنی
    - MagicWalletMinCharge، DiscountWalletMinCharge ثابت‌های مرکزی
    - سقف واقعی per-package در هندلرها اعمال می‌شود
Proto: v0.0.189
2026-02-27 08:47:46 +03:30
masoodafar-web e5bc3a952a feat: ClubMembershipCycle IHasHistory + PackageId in WalletHistory (F1)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m33s
- ClubMembershipCycle now implements IHasHistory<ClubMembershipCycleHistory>
  - CreateHistorySnapshot: New* fields from current state, Old* auto-filled by interceptor
  - Parses action string to ClubMembershipCycleAction enum

- Add PackageId to 6 UserWalletHistory creation sites (F1 fix):
  - VerifyPackagePurchaseCommandHandler: balanceLog + discountLog (order.PackageId)
  - CheckAndProcessDayaLoansCommandHandler: mainLog + discountLog (package.Id)
  - CreateManualPaymentCommandHandler: walletLog (package.Id)
  - PackageService.CustomerVerifyPackagePurchase: walletLog (purchase.PackageId)
  - Non-package flows (orders, commissions, manual) correctly keep PackageId=null
2026-02-27 07:07:30 +03:30
masoodafar-web 10d2ca20d1 refactor: rename UserWalletChangeLog→UserWalletHistory, add History interceptor & migration
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m11s
- Rename UserWalletChangeLog to UserWalletHistory across 54+ files (entities, configs, DTOs, commands, queries, protos, services)
- Rename 34 files and 11 directories accordingly
- Rename proto file userwalletchangelog.proto → userwallethistory.proto
- Add IHasHistory<T> generic interface for history auto-tracking
- Implement IHasHistory<PackageHistory> on Package entity
- Add HistoryTrackingSaveChangesInterceptor (reflection-based, auto-fills Old* values from OriginalValues)
- Wire interceptor in DI and ApplicationDbContext
- Add EF migration Q27_HistoryTables_And_RenameWalletHistory:
  * RenameTable UserWalletChangeLogs → UserWalletHistories (preserves data)
  * Rename PK, FK constraints and indexes via sp_rename
  * CreateTable ClubMembershipCycleHistories + PackageHistories
2026-02-27 06:22:15 +03:30
masoodafar-web fdbb91d2e1 feat(Q27): PackageHistory + ClubMembershipCycleHistory entities
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m28s
Q27: History Tables standardization — added missing history entities:
- PackageHistory: tracks price, activation fee, magic settings changes
- ClubMembershipCycleHistory: tracks cycle state transitions
- PackageAction + ClubMembershipCycleAction enums
- EF configurations with proper indexes and FK relationships
- DbSets registered in IApplicationDbContext + ApplicationDbContext
- Navigation properties on Package + ClubMembershipCycle entities
2026-02-27 05:31:21 +03:30
masoodafar-web a1024a3e18 feat(Q24+Q26): balance threshold + SP auto-deploy worker
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m25s
Q24: Magic wallet entry/exit trigger now uses Balance <= 1,000,000 Rials
  instead of Balance == 0 (products have varying prices so exact zero
  is unreachable). Added MagicWalletEntryThreshold to SystemConstants.

Q26: StoredProcedureDeploymentService (IHostedService) auto-deploys
  stored procedures on startup via embedded SQL resources + SHA256
  checksum comparison. Creates [CMS].[__StoredProcedureVersions] table
  automatically. Supports GO batch separators.
2026-02-27 04:22:24 +03:30
masoodafar-web 1ac23667e2 fix: package-based compliance audit — feature DIFF, remove hardcoded fallbacks
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m12s
- ActivateClubMembership: replace GetAllFeatureIds with PackageFeature DIFF (Q20)
- ActivateClubMembership: fix re-activation early return — check current cycle
- AcceptClubMembershipContract: same feature DIFF logic
- VerifyPackagePurchase: remove ?? 2.0m fallback, throw if package null
- PackageService: remove ?? 2.0m fallback for DiscountMultiplier
- UserOrderService: remove ?? 1B fallback for MagicWalletMaxDeposit
- SystemConstants: remove 9 dead [Obsolete] constants (per-package values)
2026-02-27 02:35:41 +03:30
masoodafar-web dcd11351a3 feat(T4.13): add PackageFeature CRUD support
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m41s
- Proto: add feature_ids to Create/Update/Get package messages
- Commands: add FeatureIds to Create/Update commands
- Handlers: sync PackageFeature join entities on create/update
- Queries: include PackageFeatures, map FeatureIds in responses
- Bump NuGet to v0.0.188
2026-02-26 21:59:24 +03:30
masoodafar-web aaaf7fc1ca feat(commission): add per-package filtering to commission & balance queries
- Proto: add package_id filter to 4 request messages, package_id/package_title to 4 response models
- Queries: add PackageId filter to GetUserWeeklyBalances, GetUserCommissionPayouts, GetMyCommissionPayouts, GetMyWeeklyBalances
- Handlers: include Package navigation, filter by PackageId, map PackageId/PackageTitle
- DTOs: add PackageId + PackageTitle to all response models
- Mappings: update CommissionProfile with PackageId/PackageTitle for all admin+customer mappings
- NuGet: bump proto version to 0.0.187
2026-02-26 21:07:59 +03:30
masoodafar-web 7554d7054a Phase 8d: Proto cleanup — delete 4 deprecated RPCs + 8 deprecated messages
- Deleted RPCs: PurchaseGoldenPackage, VerifyGoldenPackagePurchase,
  InitiateBasePackagePayment, VerifyBasePackagePayment
- Deleted messages: PurchaseGoldenPackage{Request,Response},
  VerifyGoldenPackagePurchase{Request,Response},
  InitiateBasePackagePayment{Request,Response},
  VerifyBasePackagePayment{Request,Response}
- Kept: GetUserPackageStatus (still in use)
- NuGet version bump: 0.0.185 → 0.0.186
2026-02-26 18:00:40 +03:30
masoodafar-web ce8e248843 Phase 8b: Bump Protobuf NuGet to v0.0.185 2026-02-26 17:42:59 +03:30
masoodafar-web 8446e0e3b5 Phase 7c: Delete deprecated handlers + clean service/mapping refs
- Delete 4 deprecated CQRS handler folders (12 files):
  - PurchaseGoldenPackage/ (Command, Handler, Validator)
  - VerifyGoldenPackagePurchase/ (Command, Handler, Validator)
  - InitiateBasePackagePayment/ (Command, Handler, Validator)
  - VerifyBasePackagePayment/ (Command, Handler, Validator)
- PackageService.cs: Remove 4 gRPC override methods that dispatched
  to deleted handlers (proto RPCs now auto-throw Unimplemented)
- PackageProfile.cs: Remove all Mapster mappings for deleted types
  (6 mapping blocks + 4 using directives removed)
- Customer* RPCs remain fully functional
- Build: 0 errors
2026-02-26 03:02:17 +03:30
masoodafar-web 161f796cd4 Phase 7b: Embed orderId in callback URL for CustomerPurchasePackage
- CustomerPurchasePackage: append orderId to callback URL before sending to gateway
- Ensures PaymentCallback page receives orderId via query string for verification
2026-02-26 02:34:47 +03:30
masoodafar-web 469d97bb60 Phase 7a: Cosmetic cleanup + delete orphaned PurchasePackage handler
- Delete orphaned PurchasePackageCommand handler (no RPC dispatch, no callers)
- Fix doc-comments: طلایی → پکیج in enums, entities, handlers
- Remove stale 56M inline comments
- Clean SystemConstants deprecated field docs
2026-02-26 02:01:33 +03:30
masoodafar-web d19c569aae Phase 6: Deprecation cleanup + ConfigurationService MagicWallet
- Mark PurchaseGoldenPackage/VerifyGoldenPackagePurchase RPCs as deprecated
- Mark InitiateBasePackagePayment/VerifyBasePackagePayment RPCs as deprecated
- Remove deprecated SystemConstants from GetAllAsDict/GetAllWithDescriptions helpers
- Add MagicWallet per-package values to ConfigurationService (Multiplier, MaxDeposit, MaxCredit)
- All deprecated constants have zero active code usages — safe dead code
2026-02-26 01:10:43 +03:30
masoodafar-web 7176fe44ee Fix SP: cm.PackageId → cm.LastPackageId (match actual DB column name) 2026-02-26 00:59:59 +03:30
masoodafar-web 607f791b65 Phase 5: Per-package commission + golden ref cleanup
- Fix golden/طلایی string refs in user-facing messages (ActivateClubMembership)
- Rename HasPurchasedGoldenPackage → HasPurchasedPackage (DTO, Handler, Proto, Mapping)
- ORM commission: per-user-package calculation via ClubMembership.LastPackageId
  - Build userPackageMap, per-user maxBalancesPerLeg/maxNetworkLevel
  - Carryover keyed by (UserId, PackageId) tuple
- SP commission: loop over packages, pass @PackageId/@InputMaxBalancesPerLeg/@InputMaxNetworkLevel
- sp_CalculateWeeklyBalances: accept 3 new params, filter by PackageId, insert PackageId column
2026-02-26 00:57:20 +03:30
masoodafar-web 0002a5a6f2 Phase 4: Complete Package CRUD DTOs + Legacy Fixes
- CreateNewPackageCommand: Add 12 new Package fields (SortOrder, IsActive, IsBasePackage,
  SupportsDayaPurchase, SupportsDirectPurchase, ActivationFee, DiscountMultiplier,
  MagicWalletMultiplier, MaxBalancesPerLeg, MaxNetworkLevel, MagicWalletMaxDeposit,
  MagicWalletMaxCredit) with sensible defaults
- GetPackageResponseDto: Add 12 new fields (Mapster auto-maps via ProjectToType)
- GetAllPackageByFilterResponseModel: Add 12 new fields (Mapster auto-maps)
- PurchaseGoldenPackage: Replace fragile Title string match ('طلایی'/'golden')
  with proper IsDeleted/IsActive/SupportsDirectPurchase validation
- VerifyPackagePurchase: Replace hardcoded order.Amount*2 with
  package.DiscountMultiplier from DB (fallback 2.0 for null Package)
2026-02-26 00:24:35 +03:30
masoodafar-web ccb938e9ba Phase 3: Package layer overhaul — fix critical bugs + proto enhancement
Proto (package.proto):
- Add 11 new Package fields to Create/Update/Get/GetAll messages
- Add 8 new fields to CustomerPackageModel for frontend

Critical Bug Fixes:
- VerifyGoldenPackagePurchase: Replace hardcoded ×2 with package.DiscountMultiplier from DB

Query Fixes:
- GetAllPackageByFilter: Add IsDeleted filter (was returning soft-deleted packages)
- GetCustomerPackages: Apply IsDeleted+IncludeInactive filters, sort by SortOrder, return new fields
- GetCustomerPackageDetails: Load PackageFeatures from DB (was hardcoded), add IsDeleted filter, return new fields
- GetCustomerPurchaseHistory: Include Transaction (was null → ReferenceCode always empty)

UpdatePackage: Add 12 new fields (SortOrder, IsActive, IsBasePackage, etc.)

Build: 0 errors
2026-02-25 23:50:09 +03:30
masoodafar-web 8e5c7c5205 Phase 2: Replace all deprecated SystemConstants with Package entity reads
- ActivateClubMembership: Load Package, set First/Last fields, PackageId on Cycle+Pool
- AcceptClubMembershipContract: Load Package, set First/Last fields, PackageId on Pool
- CalculateWeeklyBalances: Load base package, use package.MaxBalancesPerLeg/MaxNetworkLevel, set PackageId on NetworkWeeklyBalance
- ProcessUserPayouts: Load base package, use package.MaxNetworkLevel, set PackageId on UserCommissionPayout
- CheckAndProcessDayaLoans: Load base package (IsBasePackage), replace DayaLoanAmount→package.Price, fix hardcoded p.Id==4
- CreateManualPayment: Load base package, replace BasePackageAmount→package.Price, use DiscountMultiplier
- InitiateBasePackagePayment: Remove hardcoded BasePackageId=4, load from IsBasePackage, use package.Price/Id
- VerifyBasePackagePayment: Load Package from order.PackageId, use package.Price+DiscountMultiplier
- ChargeMagicWallet: Load user's package from current cycle, use package.MagicWalletMaxDeposit
- VerifyMagicWalletCharge: Load user's package, use package.MagicWalletMultiplier/MagicWalletMaxDeposit
- OrmCommissionCalculationStrategy: Load base package in both methods, set PackageId on Balance+Payout
- ConfigurationService: Inject IApplicationDbContext, read package values from DB instead of SystemConstants
- UserOrderService: Load user's package for EXIT trigger MagicWalletMaxDeposit check
- UserWalletService: Load user's package for GetMagicWalletStatus display

All 9 deprecated SystemConstants now have ZERO usages outside SystemConstants.cs
Build: 0 errors, 521 warnings (all pre-existing)
2026-02-25 23:25:18 +03:30
masoodafar-web a9cd2fd67a Phase 1.5: EF Migration + Data Seed + Backfill
MIGRATION (AddPackageBasedSystem):
- Adds 11 new columns to Packages table
- Creates PackageFeatures table (Package×ClubFeature junction)
- Adds PackageId FK to: ClubMembershipCycles, WeeklyCommissionPools,
  UserCommissionPayouts, NetworkWeeklyBalances, UserWalletChangeLogs
- Adds First/LastActivationDate, First/LastPackageId to ClubMemberships
- Drops old unique indexes, creates new composite unique indexes:
  (WeekDef,Package), (User,WeekDef,Package)

DATA MIGRATION (embedded in EF migration):
1. Seeds Golden Package (Id=1): Price=56M, ActivationFee=25.2M,
   DiscountMultiplier=2.0, MagicWalletMultiplier=2.5, MaxBalancesPerLeg=300,
   MaxNetworkLevel=15, MagicWalletMaxDeposit=1B, MagicWalletMaxCredit=2.5B
2. Backfills PackageId=1 on all existing commission/network records
3. Backfills ClubMembership First/Last fields from existing ActivatedAt

FIXES:
- ClubMembership First/Last fields now nullable (DateTime?, long?)
  for backward compat with existing records
- UserPackagePurchaseConfiguration: .WithMany() → .WithMany(p => p.Purchases)
  to prevent shadow FK PackageId1

Build: 0 errors, 73 warnings
2026-02-25 22:49:12 +03:30
masoodafar-web ae92ab8697 Phase 1: Package-Based Domain Infrastructure
ENTITIES:
- Package: +11 fields (SortOrder, IsActive, IsBasePackage, SupportsDaya/Direct,
  ActivationFee, DiscountMultiplier, MagicWalletMultiplier, MaxBalancesPerLeg,
  MaxNetworkLevel, MagicWalletMaxDeposit/Credit) + nav props
- PackageFeature: NEW junction entity (Package × ClubFeature) for feature DIFF
- ClubMembership: +4 fields (FirstActivationDate/PackageId, LastActivation/PackageId)
  ActivatedAt kept as [Obsolete] for backward compat
- ClubMembershipCycle: +PackageId FK (tracks which package per cycle)
- WeeklyCommissionPool: +PackageId FK (separate pool per package)
- UserCommissionPayout: +PackageId FK (payout per package)
- NetworkWeeklyBalance: +PackageId FK (balance per package)
- UserWalletChangeLog: +PackageId? FK (optional package tracking)

EF CONFIGS:
- PackageConfiguration: 11 new field configs + indexes
- PackageFeatureConfiguration: NEW (unique PackageId+ClubFeatureId)
- ClubMembershipConfiguration: First/Last Package FKs + LastActivationDate index
- ClubMembershipCycleConfiguration: Package FK
- WeeklyCommissionPoolConfiguration: Package FK + unique(WeekDef,Package)
- UserCommissionPayoutConfiguration: Package FK + unique(User,WeekDef,Package)
- NetworkWeeklyBalanceConfiguration: Package FK + unique(User,WeekDef,Package)
- UserWalletChangeLogConfiguration: Package FK (nullable)

DBCONTEXT:
- DbSet<PackageFeature> added to ApplicationDbContext + IApplicationDbContext

SYSTEMCONSTANTS:
- 9 constants marked [Obsolete] → moved to Package entity
  (ClubMembershipGiftValue, ClubActivationFee, BasePackageAmount, DayaLoanAmount,
   MagicWalletMultiplier, MagicWalletMaxDeposit, MagicWalletMaxCredit,
   CommissionMaxWeeklyBalancesPerLeg, CommissionMaxNetworkLevel)

Build: 0 errors, 610 warnings (40 new = expected Obsolete CS0618)
2026-02-25 22:32:42 +03:30
masoodafar-web fe3edd178d fix(B6): EXIT Magic Mode — reset PackagePurchaseMethod + close current cycle
B6: PackagePurchaseMethod was NOT reset when exiting Magic Mode.
This caused Guards G1-G3 to permanently block re-purchase.

Changes:
- user.PackagePurchaseMethod = None (allows re-purchase via G1-G3)
- currentCycle.IsCurrentCycle = false (ready for new cycle)

Note: membership.IsActive is intentionally preserved (Q19 — contract is one-time).
Features are preserved — DIFF will be applied on next purchase (Q20).
2026-02-25 21:59:30 +03:30
masoodafar-web 13dd0f552f refactor: archive 12 dead gRPC RPCs with [ARCHIVED] markers
Archived RPCs (code preserved, marked obsolete):
- fms.proto: excluded from csproj (CreateNewFileInfo, DeleteFileInfo)
- UserOrderService: GetOrdersByDateRange, CreateNewOrderForCustomer, SubmitOrderForCustomer
- ConfigurationService: DeactivateConfiguration, GetConfigurationHistory
- CityService: UpdateCity, DeleteCity
- HealthService: GetServiceHealth
- CategoryService: GetCategoryByIdForCustomer
- inventory.proto: BulkAdjustStock (no implementation existed)

All archived RPCs wrapped in #region [ARCHIVED] with [Obsolete] attributes.
No code deleted — archive only. Build passes with 0 errors.
2026-02-24 22:53:36 +03:30
masoodafar-web 8b9c317de6 fix: Phase 0 — critical bugs in package purchase handlers
B1: VerifyGoldenPackagePurchase — DiscountBalance was NOT charged (Amount × 2)
    Now charges DiscountBalance same as all other handlers.

B2: VerifyGoldenPackagePurchase — UserPackagePurchase record was NOT created
    Now creates UPP with OrderId and TransactionId.

B3: VerifyPackagePurchase — UserPackagePurchase record was NOT created
    Now creates UPP after wallet charge.

B4: VerifyBasePackagePayment — UserPackagePurchase record was NOT created
    Now creates UPP after wallet change log.

Also: VerifyGolden WalletChangeLog split into 2 logs (Balance + Discount)
      to match the pattern used by VerifyPackagePurchase.
      Transaction description changed from 'پکیج طلایی' to 'پکیج' (generic).

Impact: Only Daya Loan handler was creating UserPackagePurchase before.
        Now all 4 payment paths create it consistently.
2026-02-24 21:52:36 +03:30
masoodafar-web cbaa20f339 fix: remove double ×10 Rial conversion in ZarinPalPaymentService
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
Root cause: FrontOffice already converts Toman→Rial (×10) before sending
to CMS. ZarinPalPaymentService was multiplying by 10 AGAIN, causing
amounts to be 10x too large.

Example: user enters 500K Toman → FO sends 5M Rial → CMS did ×10 → 50M
Rial sent to ZarinPal → showed 5M Toman instead of 500K.

Changes:
- ZarinPalPaymentService.InitiatePaymentAsync: remove ×10 (amount already Rial)
- ZarinPalPaymentService.VerifyPaymentWithAmountAsync: remove ×10 + accept Rial
- ZarinPalPaymentService.VerifyResult.Amount: return Rial (no /10 conversion)
- VerifyMagicWalletChargeCommandHandler: remove /10 before calling Verify
- PaymentCallbackController: update comments (amount is Rial)
- Also includes: improved HTTP error logging for non-200 responses
- Also includes: production URL fix (kbs1→kbs2)
2026-02-24 00:49:16 +03:30
masoodafar-web e206b71186 fix: reduce discount order expiry from 30 to 15 minutes
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-24 00:24:02 +03:30
masoodafar-web 0457ef6c7c fix: validate discount wallet balance before applying discount in PlaceOrderCommandHandler
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m36s
2026-02-23 23:06:37 +03:30
masoodafar-web f3ac5ad7df fix: add missing UserWalletChangeLog for discount shop purchases and discount wallet charge
Bug: when buying from discount shop, DiscountBalance was deducted from DB
but no UserWalletChangeLog was created — making it invisible in wallet history.
Same issue existed for discount wallet top-up (charge).

Fixed in 3 handlers:
- PlaceOrderCommandHandler (fully paid by discount balance path)
- CompleteOrderPaymentCommandHandler (gateway + discount balance path)
- VerifyDiscountWalletChargeCommandHandler (discount wallet charge)
2026-02-23 22:59:00 +03:30
masoodafar-web e72673c184 chore(staging): remove appsettings.Production.json — config comes from K8s Secret
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m42s
DO NOT cherry-pick this commit to production branch
2026-02-23 22:07:58 +03:30
masoodafar-web 9288d0640b feat: externalize appsettings to K8s Secret — config persists independently of image rebuilds
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m57s
- Add cms-config.yaml (K8s Secret) for staging and production
- Mount appsettings.{Environment}.json from Secret into /app/
- Update CI/CD pipelines to apply Secret before Deployment
- Remove redundant env vars (Kestrel, FileStorage) — now in config file
2026-02-23 21:58:05 +03:30
masoodafar-web de83c31346 fix: production uses namespace default + remove foursat namespace references
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m18s
2026-02-23 21:36:08 +03:30
masoodafar-web f8dc4abd04 fix: staging ASPNETCORE_ENVIRONMENT=Staging, remove secretKeyRef (config in appsettings)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m28s
2026-02-23 21:17:23 +03:30
masoodafar-web 2d6c95e6ee fix: use local registry 194.5.195.53:30080 instead of git.se.kbs1.ir for image pull
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 11m28s
2026-02-23 20:58:16 +03:30
masoodafar-web e41747afe0 fix: production ingress — add cms.kbs2.ir, use ingressClassName instead of annotation
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 12m6s
2026-02-23 20:41:12 +03:30
masoodafar-web 68da3f45a3 fix: staging uses namespace default, not foursat
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-23 20:38:27 +03:30
masoodafar-web 3153fd8a74 feat: add PersistentVolume for CMS uploads + apply manifests in CI/CD
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 12m12s
- Add k8s/staging/ and k8s/production/ deployment manifests
- PVC 20Gi for /app/Uploads (file uploads persist across pod restarts)
- FileStorage__UploadPath env var set to /app/Uploads
- Replicas reduced to 1 (ReadWriteOnce PVC)
- Pipelines now: create namespace → apply manifests → restart/update
2026-02-23 20:11:56 +03:30
masoodafar-web 8cdb197ee7 chore: prevent appsettings.Production.json from merging — keep ours strategy
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m15s
2026-02-23 00:01:56 +03:30
masoodafar-web 2ca3fd29f9 feat: admin force activation — skip order/wallet checks for BackOffice
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m12s
- Proto: add force_activation field to ActivateClubMembershipRequest
- Command: add ForceActivation property
- Handler: wrap steps 2-5 in ForceActivation guard
  - Skip PackagePurchaseMethod check (set to DirectPurchase if None)
  - Skip wallet balance check
  - Skip Magic wallet mode check
  - Skip UserOrder/Transaction validation
- Proto NuGet bumped to 0.0.184
2026-02-22 23:51:39 +03:30
masoodafar-web b9a72b4589 fix: environment-specific gateway config — staging=sandbox, production=real
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m37s
- appsettings.json/Staging.json: UseSandbox=true (sandbox mode for testing)
- appsettings.Production.json: UseSandbox=false, MerchantId=new production key
- Added SeedWorkers, Kestrel, Seq overrides to Production config
2026-02-22 22:18:51 +03:30
masoodafar-web 5c63dc6e38 feat: activate ZarinPal gateway with production MerchantId
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- MerchantId: 4225d555-5fa9-4df0-9b61-1ce152cbbba8
- UseSandbox: false (production mode)
2026-02-22 22:12:02 +03:30
masoodafar-web 52f6af2091 feat: add user_name to wallet response + enrich via Users table
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m59s
- userwallet.proto: added string user_name = 5 to GetAllUserWalletByFilterResponseModel
- UserWalletService: enriches response with user names from Users table
- Proto version bumped to 0.0.183
2026-02-22 22:02:54 +03:30
masoodafar-web e3fbd45739 fix: expand DiscountProducts.FullInformation from nvarchar(2000) to nvarchar(max) — HTML content was getting truncated
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m17s
2026-02-22 21:44:03 +03:30
masoodafar-web 0fe4db5e77 fix: StockMovement CHECK constraint violation — store Quantity as negative for reduce operations, pass correct MovementType from RecordLoss
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-22 21:38:20 +03:30
masoodafar-web 305b8d3fd8 feat: add MagicWalletCycleSeedService — one-time BackgroundService to seed ClubMembershipCycle for existing active memberships, controlled via appsettings SeedWorkers:MagicWalletCycleSeed:Enabled
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m36s
2026-02-22 21:14:09 +03:30
masoodafar-web df976b7b80 fix: Update project version to 0.0.182
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 9m5s
2026-02-22 20:48:26 +03:30
masoodafar-web b90aa50fac feat: ChargeDiscountWallet - callback endpoint, gRPC RPC & service implementation
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 10m43s
- Add /api/wallet/verify-discount-charge callback endpoint in PaymentCallbackController
- Add InitiateDiscountCharge RPC + request/response messages in userwallet.proto
- Add InitiateDiscountCharge gRPC service override in UserWalletService
- Add ChargeDiscountWallet using import for handler resolution
2026-02-22 20:22:44 +03:30
masoodafar-web 58d419ced3 feat: Magic Wallet Phase 6 + Daya restriction + purchase cycle count
- ActivateClubMembershipCommandHandler: block activation in Magic mode
- CheckAndProcessDayaLoansCommandHandler: reject Daya after first cycle
- userwallet.proto: add purchase_cycle_count to GetMagicWalletStatusResponse
- UserWalletService: query ClubMembershipCycles count for cycle info
- EF Migration u21: ClubMembershipCycle table + UserWallet Magic fields
2026-02-22 20:04:13 +03:30
masoodafar-web bdd2c51726 fix: Update project version to 0.0.181 and adjust NuGet package path
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
2026-02-21 20:37:14 +03:30
masoodafar-web 2a569a024f feat: Implement Magic Wallet functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m36s
- Added ClubMembershipCycle entity and DbSet to IApplicationDbContext.
- Updated VAT rate from 9% to 10% in VatCalculator and related areas.
- Introduced Magic Wallet settings in SystemConstants.
- Enhanced UserWallet entity to support Magic Wallet features.
- Updated TransactionType enum to include Magic Wallet transactions.
- Configured UserWallet to handle Magic Wallet properties in ApplicationDbContext.
- Implemented OrmCommissionCalculationStrategy to exclude Magic Wallet users from commission calculations.
- Added Protobuf definitions for Magic Wallet methods and responses.
- Created PaymentCallbackController endpoint for handling Magic Wallet charge callbacks.
- Updated UserOrderService to manage Magic Wallet state transitions.
- Developed UserWalletService to support Magic Wallet operations.
- Created ChargeMagicWalletCommand and its handler for initiating Magic Wallet charges.
- Implemented VerifyMagicWalletChargeCommand and handler for payment verification.
- Added validation for ChargeMagicWalletCommand.
- Established ClubMembershipCycle configuration for EF Core.
- Introduced WalletMode enum to differentiate between Normal and Magic modes.
2026-02-21 19:50:28 +03:30
masoodafar-web 04e8c49fa7 fix: IPG DiscountBalance should be 2× BasePackageAmount (112M not 56M)
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m44s
Both VerifyBasePackagePayment and VerifyPackagePurchase handlers were
charging DiscountBalance with 1× amount instead of 2×.
Now consistent with DayaLoan and ManualPayment handlers.

- VerifyBasePackagePaymentCommandHandler: DiscountBalance += BasePackageAmount * 2
- VerifyPackagePurchaseCommandHandler: DiscountBalance += order.Amount * 2
- Change logs updated to reflect correct 2× discount value
2026-02-18 23:12:03 +03:30
masoodafar-web f968a6c005 docs: move README to totalDoc, replace with placeholder
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 7m50s
2026-02-18 21:44:46 +03:30
masoodafar-web bc710fdb7b feat: auto-create inventory records for new products + migration worker
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m52s
- CreateNewProductsCommandHandler: inject IInventoryService, auto-init
  inventory record with qty=0 when a new regular product is created
  (DiscountProduct handler already had this)
- InventoryInitializerService: one-time BackgroundService that runs on
  startup, finds existing products without InventoryItem records, and
  creates them (migration for legacy products)
- Register InventoryInitializerService in DI
2026-02-18 00:42:41 +03:30
masoodafar-web fd24dcebcd feat: Add SitePageSettings - simplified page management system
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m2s
- New entities: SitePageSettings + SitePageImage (4 fixed pages)
- EF Migration: AddSitePageSettings (SitePageSettings + SitePageImages tables)
- Proto: sitepagesettings.proto with 5 RPCs
- CQRS: GetPageSettings, GetAllPageSettings, SavePageSettings, SavePageImage, DeletePageImage
- gRPC Service: SitePageSettingsService (auto-registered)
- Proto NuGet bumped to 0.0.180
2026-02-17 22:34:29 +03:30
masoodafar-web 89e146bf32 fix: set Kestrel protocol to Http1AndHttp2 in appsettings — prevents 502
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m38s
2026-02-17 02:55:50 +03:30
384 changed files with 46705 additions and 3690 deletions
+2
View File
@@ -0,0 +1,2 @@
# Keep production config from current branch during merges — never overwrite
src/CMSMicroservice.WebApi/appsettings.Production.json merge=ours
+15 -5
View File
@@ -58,7 +58,7 @@ jobs:
- name: Publish Protobuf packages
run: |
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 '
for proj in $(find . -name "*Protobuf*.csproj" -type f); do
echo "📦 $proj"
@@ -67,9 +67,9 @@ jobs:
dotnet pack "$proj" -c Release --no-build -o "$(dirname $proj)/nupkg"
for nupkg in $(dirname $proj)/nupkg/*.nupkg; do
[ -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" \
--skip-duplicate --allow-insecure-connections || true
--skip-duplicate --configfile src/NuGet.config || true
done
done
'
@@ -86,8 +86,18 @@ jobs:
- name: Deploy to Kubernetes
run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
# Copy K8s manifests to server
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/cms-config.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-config.yaml
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/cms-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-deployment.yaml
# Apply config (Secret) first, then deployment
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl rollout restart deployment/cms
kubectl rollout status deployment/cms --timeout=180s
kubectl apply -f /tmp/cms-config.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 status deployment/cms --timeout=180s &&
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
"
echo "✅ Deployed!"
+11 -2
View File
@@ -86,8 +86,17 @@ jobs:
- name: Deploy to Production
run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
# Copy K8s manifests to server
sshpass -e scp -o StrictHostKeyChecking=no k8s/production/cms-config.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-config.yaml
sshpass -e scp -o StrictHostKeyChecking=no k8s/production/cms-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/cms-deployment.yaml
# Apply config (Secret) first, then deployment and update image
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl set image deployment/cms cms=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
kubectl rollout status deployment/cms --timeout=300s
kubectl apply -f /tmp/cms-config.yaml &&
kubectl apply -f /tmp/cms-deployment.yaml &&
kubectl set image deployment/cms cms=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} &&
kubectl rollout status deployment/cms --timeout=300s &&
rm -f /tmp/cms-config.yaml /tmp/cms-deployment.yaml
"
echo "✅ Deployed to Production!"
+3 -444
View File
@@ -1,445 +1,4 @@
# CMS Microservice - Network & Club Commission + Inventory Management System
# CMS Microservice
[![Status](https://img.shields.io/badge/Status-Active%20Development-success)]()
[![Progress](https://img.shields.io/badge/Inventory%20System-Phase%202%20Complete-blue)]()
[![Phase](https://img.shields.io/badge/Next-Business%20Services-orange)]()
## 📊 Project Status (January 2026)
### 🏪 Inventory Management System - NEW!
**Progress**: Phase 2 Complete (50%)
**Architecture**: Clean Architecture + CQRS + Repository Pattern
#### ✅ Completed Phases
1.**Phase 1: Infrastructure & Domain Layer**
- Domain Entities: `InventoryItem`, `StockMovement`, `Warehouse`
- Domain Enums: `StockMovementType`
- EF Core Configurations with proper indexing
- Database migration applied
2.**Phase 2: Repository Pattern & CQRS**
- Repository Interfaces & Implementations
- CQRS Commands (17 commands)
- CQRS Queries (35 queries)
- MediatR Handlers (52 handlers)
#### 🔄 In Progress
3. 🔄 **Phase 3: Business Services Layer**
4.**Phase 4: DTOs & AutoMapper**
5.**Phase 5: API Controllers**
---
### 💼 Commission System - Production Ready
**Progress**: 85% Complete
**MVP Status**: ✅ 100% Complete
#### ✅ Completed Features
- ✅ Binary network tree with automatic placement
- ✅ Club membership (Member/Trial) with commission rates
- ✅ Weekly commission calculation (Lesser Leg algorithm)
- ✅ Background worker with Hangfire
- ✅ Email + SMS notifications (MailKit + Kavenegar)
- ✅ Health check endpoints (Kubernetes-ready)
### 🟡 Partially Complete
- Phase 10: Withdrawal & Settlement (40%)
- ✅ Commands & Database
- ❌ Payment Gateway Integration
### ❌ Not Started
- Phase 9: Club Shop & Product Integration (0%)
---
## 🚀 Recent Updates (January 2026)
### 🏪 Inventory Management System - NEW! ✅
**Complete CQRS-based inventory management with:**
#### Domain Layer:
-`InventoryItem` - Multi-warehouse product tracking with min/max thresholds
-`StockMovement` - Complete audit trail with 8 movement types
-`Warehouse` - Multi-location support with default warehouse
#### Repository Pattern:
-`IInventoryItemRepository` - 25+ methods for inventory operations
-`IStockMovementRepository` - Movement tracking & analytics
-`IWarehouseRepository` - Warehouse management & statistics
#### CQRS Commands (17 total):
- **Inventory:** Create, Update, Delete, Reserve, Release, Reduce, Increase
- **Movement:** Create, BulkCreate, Delete
- **Warehouse:** Create, Update, Delete, SetDefault, Activate, BulkCreate
#### CQRS Queries (35 total):
- **Inventory:** GetById, Search, LowStock, OutOfStock, CheckAvailability
- **Movement:** GetHistory, GetByOrder, Search, Analytics, DailyVolume, TopMoving
- **Warehouse:** GetById, Search, GetStats, GetLowStock, GetAllStats
#### Business Features:
- ✅ Multi-warehouse inventory management
- ✅ Stock reservation system for orders
- ✅ Automatic movement tracking
- ✅ Low stock & out-of-stock alerts
- ✅ Advanced analytics & reporting
- ✅ Bulk operations support
- ✅ Transaction-safe operations
---
### Email & SMS Notifications - COMPLETED ✅
-**MailKit 4.14.1** for Email (SMTP with HTML templates)
-**Kavenegar 1.2.5** for SMS (Iranian SMS gateway)
- ✅ User.Email field added with migration
- ✅ 3 notification types: Commission, Club activation, Errors
- ✅ Persian RTL templates with rich formatting
- ✅ Production configuration guide created
### Hangfire Job Scheduling - COMPLETED ✅
- ✅ Dashboard UI at `/hangfire`
- ✅ Cron schedule: Sunday 00:05 UTC
- ✅ SQL Server persistence
- ✅ Manual trigger API endpoints
- ✅ Distributed execution support
### Infrastructure Enhancements - COMPLETED ✅
- ✅ Health Check endpoints (`/health`, `/health/ready`, `/health/live`)
- ✅ AlertService (structured logging for Sentry/Slack)
- ✅ Retry logic (Polly 8.5.0 with exponential backoff)
- ✅ WorkerExecutionLog (database audit trail)
- ✅ CurrentUserService (JWT authentication context)
---
## 🏗️ Architecture
**Clean Architecture** with 4 layers:
```
CMSMicroservice.Domain/ # Entities, Enums, Interfaces
├── Entities/
│ ├── InventoryItem.cs # NEW: Inventory tracking
│ ├── StockMovement.cs # NEW: Movement audit
│ └── Warehouse.cs # NEW: Multi-warehouse
├── Enums/
│ └── StockMovementType.cs # NEW: Movement types
CMSMicroservice.Application/ # CQRS (Commands, Queries, MediatR)
├── Features/
│ ├── InventoryItems/ # NEW: Inventory CQRS
│ │ ├── Commands/
│ │ ├── Queries/
│ │ └── Handlers/
│ ├── StockMovements/ # NEW: Movement CQRS
│ │ ├── Commands/
│ │ ├── Queries/
│ │ └── Handlers/
│ └── Warehouses/ # NEW: Warehouse CQRS
│ ├── Commands/
│ ├── Queries/
│ └── Handlers/
└── Common/Interfaces/
└── Repositories/ # NEW: Repository interfaces
CMSMicroservice.Infrastructure/ # DbContext, Services, Background Jobs
├── Persistence/
│ ├── Context/
│ ├── Configurations/ # NEW: EF Core configs
│ ├── Repositories/ # NEW: Repository implementations
│ └── Migrations/
└── DependencyInjection.cs # NEW: DI setup
CMSMicroservice.WebApi/ # gRPC Services, Controllers
CMSMicroservice.Protobuf/ # Protocol Buffers definitions
```
**Technology Stack**:
- .NET 9.0
- Entity Framework Core 9.0.11
- gRPC + JSON Transcoding
- Hangfire 1.8.22 (Job Scheduling)
- MediatR 13.0.0 (CQRS)
- Polly 8.5.0 (Resilience)
- MailKit 4.14.1 (Email)
- Kavenegar 1.2.5 (SMS)
- SQL Server
---
## 📖 Documentation
- **[Development Plan](docs/development-plan.md)** - NEW: Inventory system roadmap
- **[Implementation Progress](docs/implementation-progress.md)** - Detailed phase-by-phase progress
- **[Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)** - Production setup instructions
- **[Balance Calculation Logic](docs/balance-calculation-carryover-logic.md)** - Commission algorithm details
- **[Binary Tree Registration](docs/binary-tree-registration-guide.md)** - Network tree guide
- **[Network Club Commission System](docs/network-club-commission-system-v1.1.md)** - Full system specification
---
## 🏪 Inventory System Usage
### Create Warehouse
```csharp
await mediator.Send(new CreateWarehouseCommand
{
Name = "Main Warehouse",
Code = "WH-001",
IsDefault = true,
IsActive = true
});
```
### Create Inventory Item
```csharp
await mediator.Send(new CreateInventoryItemCommand
{
ProductId = 1,
WarehouseId = 1,
Quantity = 100,
MinQuantity = 10,
MaxQuantity = 1000
});
```
### Reserve Stock for Order
```csharp
await mediator.Send(new ReserveInventoryCommand
{
Id = inventoryId,
Quantity = 5,
OrderId = 12345
});
```
### Check Availability
```csharp
bool available = await mediator.Send(
new CheckInventoryAvailabilityQuery(inventoryId, 10));
```
### Get Low Stock Alerts
```csharp
var lowStock = await mediator.Send(new GetLowStockItemsQuery
{
WarehouseId = 1,
Count = 50
});
```
### Get Movement Analytics
```csharp
var summary = await mediator.Send(new GetMovementSummaryQuery
{
FromDate = DateTime.Now.AddDays(-7),
ToDate = DateTime.Now
});
var topProducts = await mediator.Send(new GetTopMovingProductsQuery
{
FromDate = DateTime.Now.AddDays(-30),
ToDate = DateTime.Now,
Count = 10
});
```
---
## 🚀 Quick Start
### Prerequisites
- .NET 9.0 SDK
- SQL Server (local or remote)
- (Optional) Gmail account for Email
- (Optional) Kavenegar account for SMS
### 1. Clone & Build
```bash
cd /home/masoud/Apps/project/FourSat/CMS/src
dotnet build
```
### 2. Configure Database
Update `appsettings.json` with your SQL Server connection:
```json
"ConnectionStrings": {
"DefaultConnection": "Server=YOUR_SERVER;Database=Foursat_CMS;..."
}
```
### 3. Apply Migrations
```bash
cd CMSMicroservice.WebApi
dotnet ef database update
```
### 4. Configure Notifications (Optional)
See [Email/SMS Configuration Guide](docs/email-sms-configuration-guide.md)
### 5. Run
```bash
dotnet run --urls="http://localhost:5133"
```
### 6. Access Endpoints
- **Health**: http://localhost:5133/health
- **Hangfire Dashboard**: http://localhost:5133/hangfire
- **gRPC**: localhost:5133 (HTTP/2)
---
## 🔧 Configuration
### Email (SMTP)
```json
"Email": {
"Enabled": true,
"SmtpHost": "smtp.gmail.com",
"SmtpPort": 587,
"SmtpUsername": "your-email@gmail.com",
"SmtpPassword": "your-gmail-app-password",
"FromEmail": "noreply@foursat.com",
"FromName": "FourSat CMS",
"EnableSsl": true
}
```
### SMS (Kavenegar)
```json
"Sms": {
"Enabled": true,
"Provider": "Kavenegar",
"KavenegarApiKey": "YOUR_API_KEY",
"Sender": "10008663"
}
```
### Background Worker
```csharp
// Cron: "5 0 * * 0" = Every Sunday at 00:05 UTC
RecurringJob.AddOrUpdate<WeeklyCommissionJob>(
"weekly-commission-calculation",
job => job.ExecuteAsync(CancellationToken.None),
"5 0 * * 0");
```
---
## 🧪 Testing
### Manual Trigger (via API)
```bash
# Trigger weekly calculation immediately
curl -X POST http://localhost:5133/api/admin/trigger-weekly-calculation
# Trigger recurring job now
curl -X POST http://localhost:5133/api/admin/trigger-recurring-job-now
# Get recurring jobs status
curl http://localhost:5133/api/admin/recurring-jobs-status
```
### Health Checks
```bash
curl http://localhost:5133/health # Overall health
curl http://localhost:5133/health/ready # Readiness probe (K8s)
curl http://localhost:5133/health/live # Liveness probe (K8s)
```
---
## 📊 What's Remaining?
### 🏪 Inventory System (Current Focus)
1. **Phase 3: Business Services** (In Progress)
- `IInventoryManagementService` - High-level operations
- `IStockMovementService` - Movement orchestration
- `IWarehouseService` - Warehouse business logic
- `IInventoryReportingService` - Advanced reporting
2. **Phase 4: DTOs & AutoMapper** (Next)
- Request/Response DTOs
- AutoMapper profiles
- Validation rules
3. **Phase 5: API Controllers** (Planned)
- `InventoryController` - REST API
- `WarehouseController` - Warehouse management
- `StockMovementController` - Movement tracking
- Swagger documentation
### 💼 Commission System
1. **Payment Gateway Integration** (Phase 10 - 1 week)
- Daya or Bank Mellat API integration
- IBAN transfer automation
- Admin approval UI in BackOffice
2. **Production Configuration** (30 minutes)
- Gmail App Password setup
- Kavenegar API key registration
- Update `appsettings.Production.json`
### Medium Priority
3. **Club Shop Integration** (Phase 9 - 2 weeks)
- Product catalog for club memberships
- Shopping cart integration
- Auto-activation on purchase
### Low Priority
4. **Testing** (Phase 7 - Postponed)
- Unit tests for business logic
- Integration tests for API
- Load testing for background worker
### Optional Enhancements
- Redis distributed locks (multi-server deployment)
- Sentry error tracking (API key needed)
- Slack notifications (webhook needed)
- FCM push notifications
---
## 🎯 MVP Features (100% Complete)
### 💼 Commission System:
✅ Binary network tree with automatic placement
✅ Club membership (Member/Trial) with different commission rates
✅ Weekly commission calculation (Lesser Leg algorithm)
✅ Background worker with Hangfire (cron scheduling)
✅ Balance carryover logic (rollover unused volumes)
✅ MaxWeeklyBalances cap enforcement
✅ Health check endpoints (Kubernetes-ready)
✅ Manual trigger API (admin control)
✅ Email + SMS notifications (MailKit + Kavenegar)
✅ Retry logic with exponential backoff (Polly)
✅ Audit trail (WorkerExecutionLog, History tables)
✅ Structured logging (AlertService for Sentry/Slack)
✅ JWT authentication context (CurrentUserService)
### 🏪 Inventory System (Phase 2 Complete):
✅ Domain entities (InventoryItem, StockMovement, Warehouse)
✅ Multi-warehouse inventory management
✅ Stock reservation system for orders
✅ 8 movement types with complete audit trail
✅ Repository pattern with 25+ methods per repository
✅ CQRS with 17 commands and 35 queries
✅ 52 MediatR handlers with business logic
✅ Low stock and out-of-stock alerts
✅ Advanced analytics (top products, daily volume)
✅ Bulk operations support
✅ Transaction-safe operations with rollback
✅ DI container configuration
---
## 👥 Team
**Development**: FourSat Team
**Last Updated**: January 2026
---
## 📝 License
Proprietary - FourSat Company
# Multi-remote push enabled
Documentation moved to [totalDoc/INDEX.md](../totalDoc/INDEX.md).
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
---
# CMS Application Configuration Secret (Production)
# This Secret stores appsettings.Production.json externally in K8s
# so configuration persists independently of Docker image rebuilds.
#
# To edit config without redeploying:
# kubectl edit secret cms-appsettings
# kubectl rollout restart deployment/cms
#
apiVersion: v1
kind: Secret
metadata:
name: cms-appsettings
namespace: default
labels:
app: cms
environment: production
type: Opaque
stringData:
appsettings.Production.json: |
{
"PaymentProvider": "zarinpal",
"ZarinPal": {
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": false
},
"CmsBaseUrl": "https://cms.kbs2.ir",
"FrontOfficeBaseUrl": "https://kbs2.ir",
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"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": {
"MagicWalletCycleSeed": {
"Enabled": true
}
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
},
"Authentication": {
"Authority": "https://ids.domain.com/",
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "http://seq-svc:5341",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
}
}
+139
View File
@@ -0,0 +1,139 @@
---
# CMS Uploads PersistentVolumeClaim (Production)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cms-uploads-pvc
namespace: default
labels:
app: cms
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
# CMS Microservice Deployment (Production)
apiVersion: apps/v1
kind: Deployment
metadata:
name: cms
namespace: default
labels:
app: cms
spec:
replicas: 1
selector:
matchLabels:
app: cms
template:
metadata:
labels:
app: cms
spec:
containers:
- name: cms
image: 194.5.195.53:30080/admin/cms:prod
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: ASPNETCORE_URLS
value: "http://+:8080"
volumeMounts:
- name: cms-uploads
mountPath: /app/Uploads
- name: cms-config
mountPath: /app/appsettings.Production.json
subPath: appsettings.Production.json
readOnly: true
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: cms-uploads
persistentVolumeClaim:
claimName: cms-uploads-pvc
- name: cms-config
secret:
secretName: cms-appsettings
---
# CMS Service (Production)
apiVersion: v1
kind: Service
metadata:
name: cms-svc
namespace: default
spec:
selector:
app: cms
ports:
- name: http
port: 8080
targetPort: 8080
type: ClusterIP
---
# CMS Ingress (Production)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: cms-ingress
namespace: default
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- cms.kbs1.ir
- cms.kbs2.ir
secretName: cms-tls
rules:
- host: cms.kbs2.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cms-svc
port:
number: 8080
- host: cms.kbs1.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cms-svc
port:
number: 8080
+113
View File
@@ -0,0 +1,113 @@
---
# CMS Application Configuration Secret (Staging)
# This Secret stores appsettings.Staging.json externally in K8s
# so configuration persists independently of Docker image rebuilds.
#
# To edit config without redeploying:
# kubectl edit secret cms-appsettings
# kubectl rollout restart deployment/cms
#
apiVersion: v1
kind: Secret
metadata:
name: cms-appsettings
namespace: default
labels:
app: cms
environment: staging
type: Opaque
stringData:
appsettings.Staging.json: |
{
"PaymentProvider": "zarinpal",
"ZarinPal": {
"MerchantId": "4225d555-5fa9-4df0-9b61-1ce152cbbba8",
"UseSandbox": true
},
"CmsBaseUrl": "https://cms.se.kbs1.ir",
"FrontOfficeBaseUrl": "https://frontoffice.se.kbs1.ir",
"FMS": {
"Address": "https://dl.afrino.co"
},
"JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=",
"JwtIssuer": "https://localhost",
"JwtAudience": "https://localhost",
"JwtExpiryInDays": 5,
"ConnectionStrings": {
"DefaultConnection": "Data Source=194.5.195.53,31433; Initial Catalog=Foursat;User ID=sa;Password=87zH26nbqT;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False",
"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": true,
"CronExpression": "5 0 * * 0"
}
},
"SeedWorkers": {
"MagicWalletCycleSeed": {
"Enabled": true
}
},
"FileStorage": {
"UploadPath": "/app/Uploads"
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http1AndHttp2"
}
},
"Authentication": {
"Authority": "https://ids.domain.com/",
"Audience": "domain_api"
},
"Seq": {
"ServerUrl": "https://seq.afrino.co",
"ApiKey": "oxpvpUzU1pZxMS4s3Fqq"
}
}
+132
View File
@@ -0,0 +1,132 @@
---
# CMS Uploads PersistentVolumeClaim
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: cms-uploads-pvc
namespace: default
labels:
app: cms
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
# CMS Microservice Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: cms
namespace: default
labels:
app: cms
spec:
replicas: 1
selector:
matchLabels:
app: cms
template:
metadata:
labels:
app: cms
spec:
containers:
- name: cms
image: 194.5.195.53:30080/admin/cms:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
name: http
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Staging"
- name: ASPNETCORE_URLS
value: "http://+:8080"
volumeMounts:
- name: cms-uploads
mountPath: /app/Uploads
- name: cms-config
mountPath: /app/appsettings.Staging.json
subPath: appsettings.Staging.json
readOnly: true
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: cms-uploads
persistentVolumeClaim:
claimName: cms-uploads-pvc
- name: cms-config
secret:
secretName: cms-appsettings
---
# CMS Service
apiVersion: v1
kind: Service
metadata:
name: cms-svc
namespace: default
spec:
selector:
app: cms
ports:
- name: http
port: 8080
targetPort: 8080
type: ClusterIP
---
# 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
kind: Ingress
metadata:
name: cms-ingress
namespace: default
annotations:
kubernetes.io/ingress.class: "nginx"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
tls:
- hosts:
- cms.se.kbs1.ir
secretName: cms-tls
rules:
- host: cms.se.kbs1.ir
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: cms-svc
port:
number: 8080
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
@@ -32,12 +33,13 @@ public class CreateBlogPostCommandHandler : IRequestHandler<CreateBlogPostComman
if (string.IsNullOrEmpty(currentUserId) || !long.TryParse(currentUserId, out var authorUserId))
throw new UnauthorizedAccessException("کاربر احراز هویت نشده است");
var slug = BlogSlugHelper.ResolveForCreate(request.Slug, request.Title.Trim());
slug = await BlogSlugHelper.EnsureUniquePostSlugAsync(_context, slug, cancellationToken: cancellationToken);
var post = new BlogPost
{
Title = request.Title.Trim(),
Slug = string.IsNullOrWhiteSpace(request.Slug)
? $"post-{Guid.NewGuid():N}".Substring(0, 20)
: request.Slug.Trim().ToLower(),
Slug = slug,
Summary = request.Summary?.Trim(),
HtmlContent = request.HtmlContent,
FeaturedImagePath = request.FeaturedImagePath,
@@ -11,9 +11,9 @@ public class CreateBlogPostCommandValidator : AbstractValidator<CreateBlogPostCo
.MaximumLength(200).WithMessage("عنوان نمی‌تواند بیشتر از 200 کاراکتر باشد");
RuleFor(x => x.Slug)
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
.MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد")
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد")
.When(x => !string.IsNullOrWhiteSpace(x.Slug));
RuleFor(x => x.Summary)
.MaximumLength(500).WithMessage("خلاصه نمی‌تواند بیشتر از 500 کاراکتر باشد")
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Blog;
@@ -29,9 +30,21 @@ public class UpdateBlogPostCommandHandler : IRequestHandler<UpdateBlogPostComman
?? throw new KeyNotFoundException($"مقاله با شناسه {request.Id} یافت نشد");
post.Title = request.Title.Trim();
// حفظ 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.HtmlContent = request.HtmlContent;
post.FeaturedImagePath = request.FeaturedImagePath;
@@ -13,9 +13,9 @@ public class UpdateBlogPostCommandValidator : AbstractValidator<UpdateBlogPostCo
.MaximumLength(200).WithMessage("عنوان نمی‌تواند بیشتر از 200 کاراکتر باشد");
RuleFor(x => x.Slug)
.NotEmpty().WithMessage("نشانی یکتا (slug) الزامی است")
.MaximumLength(200).WithMessage("نشانی نمی‌تواند بیشتر از 200 کاراکتر باشد")
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد");
.Matches(@"^[a-z0-9\-]+$").WithMessage("نشانی فقط می‌تواند شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد")
.When(x => !string.IsNullOrWhiteSpace(x.Slug));
RuleFor(x => x.HtmlContent)
.NotEmpty().WithMessage("محتوای مقاله الزامی است");
@@ -135,13 +135,75 @@ public class AcceptClubMembershipContractCommandHandler
};
await _context.UserContracts.AddAsync(userContract, cancellationToken);
// 6. دریافت مقادیر از SystemConstants (استاتیک)
long giftValue = SystemConstants.ClubMembershipGiftValue;
long activationFeeValue = SystemConstants.ClubActivationFee;
// 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
.Where(p => p.UserId == userId)
.OrderByDescending(p => p.PurchasedAt)
.FirstOrDefaultAsync(cancellationToken);
Package? package = null;
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)
throw new NotFoundException("پکیج یافت نشد");
long giftValue = package.ActivationFee;
long activationFeeValue = package.ActivationFee;
_logger.LogInformation(
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
giftValue, activationFeeValue
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}",
package.Id, package.Title, giftValue, activationFeeValue
);
// 7. فعال‌سازی باشگاه مشتریان
@@ -156,6 +218,10 @@ public class AcceptClubMembershipContractCommandHandler
UserId = user.Id,
IsActive = true,
ActivatedAt = activationDate,
FirstActivationDate = activationDate,
FirstPackageId = package.Id,
LastActivationDate = activationDate,
LastPackageId = package.Id,
InitialContribution = activationFeeValue,
GiftValue = giftValue,
TotalEarned = 0,
@@ -175,6 +241,8 @@ public class AcceptClubMembershipContractCommandHandler
clubMembership = user.ClubMembership!;
clubMembership.IsActive = true;
clubMembership.ActivatedAt = activationDate;
clubMembership.LastActivationDate = activationDate;
clubMembership.LastPackageId = package.Id;
clubMembership.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(clubMembership);
@@ -203,16 +271,64 @@ public class AcceptClubMembershipContractCommandHandler
_context.ClubMembershipHistories.Add(history);
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 weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
if (weeklyPool == null)
{
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
PackageId = package.Id,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0,
ValuePerBalance = 0,
@@ -243,36 +359,8 @@ public class AcceptClubMembershipContractCommandHandler
await _context.SaveChangesAsync(cancellationToken);
// 10. اعطای ویژگی‌های باشگاه برای کاربر (فقط برای عضویت جدید)
if (isNewMembership)
{
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
.ToListAsync(cancellationToken);
if (clubFeatures.Any())
{
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
{
UserId = user.Id,
ClubMembershipId = clubMembership.Id,
ClubFeatureId = feature.Id,
GrantedAt = activationDate,
IsActive = true,
Notes = "اعطا شده هنگام امضای قرارداد"
}).ToList();
_context.UserClubFeatures.AddRange(userClubFeatures);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Granted {Count} club features to UserId {UserId}",
clubFeatures.Count,
user.Id
);
}
}
// 10. اعمال ویژگی‌های پکیج — DIFF/تفاضل (Q20)
await ApplyFeatureDiffAsync(user.Id, clubMembership.Id, package.Id, isNewMembership, activationDate, cancellationToken);
_logger.LogInformation(
"Club membership contract accepted and activated for UserId: {UserId}, ContractId: {ContractId}, MembershipId: {MembershipId}",
@@ -302,6 +390,61 @@ public class AcceptClubMembershipContractCommandHandler
return week.Id;
}
private async Task ApplyFeatureDiffAsync(
long userId, long membershipId, long packageId,
bool isNewMembership, DateTime activationDate,
CancellationToken cancellationToken)
{
var newFeatureIds = await _context.PackageFeatures
.Where(pf => pf.PackageId == packageId && pf.IsIncluded && !pf.IsDeleted)
.Select(pf => pf.ClubFeatureId)
.ToListAsync(cancellationToken);
if (!newFeatureIds.Any())
{
_logger.LogWarning("No PackageFeatures for PackageId {PackageId}", packageId);
return;
}
var currentFeatures = await _context.UserClubFeatures
.Where(ucf => ucf.UserId == userId && ucf.IsActive)
.ToListAsync(cancellationToken);
var currentFeatureIds = currentFeatures.Select(f => f.ClubFeatureId).ToHashSet();
var toAdd = newFeatureIds.Where(id => !currentFeatureIds.Contains(id)).ToList();
var toRemove = currentFeatures.Where(f => !newFeatureIds.Contains(f.ClubFeatureId)).ToList();
foreach (var feature in toRemove)
{
feature.IsActive = false;
feature.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {packageId})";
}
if (toAdd.Any())
{
var newUserFeatures = toAdd.Select(featureId => new UserClubFeature
{
UserId = userId,
ClubMembershipId = membershipId,
ClubFeatureId = featureId,
GrantedAt = activationDate,
IsActive = true,
Notes = isNewMembership
? "اعطا شده هنگام امضای قرارداد"
: $"اضافه شده بابت خرید مجدد پکیج (PackageId: {packageId})"
}).ToList();
_context.UserClubFeatures.AddRange(newUserFeatures);
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}",
userId, toAdd.Count, toRemove.Count,
currentFeatureIds.Count - toRemove.Count);
}
private async Task<(bool Success, string Message)> VerifyOtpAsync(
string mobile,
string code,
@@ -12,4 +12,15 @@ public record ActivateClubMembershipCommand : IRequest<bool>
/// شناسه کاربر
/// </summary>
public long UserId { get; init; }
/// <summary>
/// فعال‌سازی اجباری توسط ادمین — بدون بررسی سفارش و کیف‌پول
/// </summary>
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.Helpers;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using CMSMicroservice.Domain.Common;
@@ -54,95 +55,148 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
throw new NotFoundException(nameof(User), request.UserId);
}
// 2. بررسی اینکه پکیج خریده باشد
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
// متغیر پکیج — در هر دو مسیر (عادی و Force) مقداردهی می‌شود
Package package;
// 2-5: بررسی‌های مالی — در حالت ForceActivation (ادمین) رد می‌شود
if (!request.ForceActivation)
{
_logger.LogWarning(
"User {UserId} has not purchased golden package yet",
// 2. بررسی اینکه پکیج خریده باشد
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
_logger.LogWarning(
"User {UserId} has not purchased any package yet",
request.UserId
);
throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان ابتدا باید یک پکیج خریداری کنید"
);
}
// 3. بررسی موجودی کیف پول
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
if (wallet == null)
{
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
// NOTE: balance check uses package.Price — loaded after finding order (step 4)
// Moved to after package loading
// 3.5. بررسی وضعیت کیف‌پول جادویی — اگر در حالت Magic است، فعالسازی مجاز نیست
if (wallet.WalletMode == WalletMode.Magic)
{
_logger.LogWarning(
"User {UserId} cannot activate club while in Magic wallet mode",
request.UserId
);
throw new BadRequestException(
"ابتدا باید چرخه کیف‌پول جادویی تکمیل شود"
);
}
// 4. پیدا کردن آخرین خرید موفق پکیج
var packagePurchase = await _context.UserPackagePurchases
.Include(p => p.Transaction)
.Where(p =>
p.UserId == user.Id &&
p.Transaction != null &&
p.Transaction.PaymentStatus == PaymentStatus.Success)
.OrderByDescending(p => p.Created)
.FirstOrDefaultAsync(cancellationToken);
if (packagePurchase == null)
{
_logger.LogWarning(
"No successful package purchase found for UserId: {UserId}",
request.UserId
);
throw new NotFoundException("سفارش پکیج یافت نشد");
}
// 5. بررسی Transaction
if (packagePurchase.Transaction == null)
{
_logger.LogError(
"Transaction not found for PurchaseId: {PurchaseId}",
packagePurchase.Id
);
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
}
var transaction = packagePurchase.Transaction;
if (transaction.Type != TransactionType.Buy &&
transaction.Type != TransactionType.DepositIpg &&
transaction.Type != TransactionType.DepositExternal1)
{
_logger.LogWarning(
"Invalid transaction type for PurchaseId {PurchaseId}: {Type}",
packagePurchase.Id,
transaction.Type
);
throw new BadRequestException(
"تراکنش معتبر برای فعالسازی باشگاه یافت نشد"
);
}
// 5.5. بارگذاری پکیج از خرید
package = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == packagePurchase.PackageId && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException("پکیج یافت نشد");
// بررسی موجودی با مبلغ پکیج واقعی
if (wallet.Balance < package.Price)
{
throw new BadRequestException(
$"برای فعالسازی باشگاه مشتریان باید حداقل {package.Price:N0} تومان موجودی اصلی داشته باشید"
);
}
}
else
{
_logger.LogInformation(
"Force activation by admin for UserId: {UserId} — skipping order/wallet validation",
request.UserId
);
throw new BadRequestException(
"برای فعالسازی باشگاه مشتریان ابتدا باید پکیج طلایی خریداری کنید"
);
}
// 3. بررسی موجودی کیف پول
var wallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == user.Id, cancellationToken);
// در فعال‌سازی اجباری ادمین، اگر روش خرید تنظیم نشده، دستی در نظر بگیر
if (user.PackagePurchaseMethod == PackagePurchaseMethod.None)
{
user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
_context.Users.Update(user);
await _context.SaveChangesAsync(cancellationToken);
}
if (wallet == null)
{
_logger.LogError("Wallet not found for UserId: {UserId}", request.UserId);
throw new NotFoundException("کیف پول کاربر یافت نشد");
}
if (wallet.Balance < SystemConstants.BasePackageAmount)
{
_logger.LogWarning(
"User {UserId} has insufficient balance: {Balance}",
request.UserId,
wallet.Balance
);
throw new BadRequestException(
$"برای فعالسازی باشگاه مشتریان باید حداقل {SystemConstants.BasePackageAmount:N0} ریال موجودی اصلی داشته باشید"
);
}
// 4. پیدا کردن UserOrder با PackageId
var packageOrder = await _context.UserOrders
.Include(o => o.Transaction)
.Where(o =>
o.UserId == user.Id &&
o.PackageId != null &&
o.PaymentStatus == PaymentStatus.Success)
.OrderByDescending(o => o.Created)
.FirstOrDefaultAsync(cancellationToken);
if (packageOrder == null)
{
_logger.LogWarning(
"No successful package order found for UserId: {UserId}",
request.UserId
);
throw new NotFoundException("سفارش پکیج طلایی یافت نشد");
}
// 5. بررسی Transaction
if (packageOrder.Transaction == null)
{
_logger.LogError(
"Transaction not found for OrderId: {OrderId}",
packageOrder.Id
);
throw new NotFoundException("تراکنش مربوط به سفارش یافت نشد");
}
var transaction = packageOrder.Transaction;
if (transaction.Type != TransactionType.DepositIpg &&
transaction.Type != TransactionType.DepositExternal1)
{
_logger.LogWarning(
"Invalid transaction type for OrderId {OrderId}: {Type}",
packageOrder.Id,
transaction.Type
);
throw new BadRequestException(
"تراکنش معتبر برای فعالسازی باشگاه یافت نشد"
);
// بارگذاری پکیج — اگر 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
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new NotFoundException("پکیج پایه یافت نشد");
}
}
// 6. بررسی عضویت فعلی
var existingMembership = await _context.ClubMemberships
.FirstOrDefaultAsync(c => c.UserId == user.Id, cancellationToken);
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از SystemConstants
long giftValue = SystemConstants.ClubMembershipGiftValue;
long activationFeeValue = SystemConstants.ClubActivationFee;
// 6.1. دریافت مبلغ هدیه و هزینه فعالسازی از پکیج
long giftValue = package.ActivationFee;
long activationFeeValue = package.ActivationFee;
_logger.LogInformation(
"Using Club.MembershipGiftValue: {GiftValue}, Club.ActivationFee: {ActivationFee}",
giftValue, activationFeeValue
"Using Package {PackageId} ({PackageName}): GiftValue={GiftValue}, ActivationFee={ActivationFee}, Price={Price}",
package.Id, package.Title, giftValue, activationFeeValue, package.Price
);
ClubMembership entity;
@@ -151,14 +205,18 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
if (isNewMembership)
{
// ایجاد عضویت جدید
// ایجاد عضویت جدید — IsActive = false تا زمان امضای قرارداد باشگاه
entity = new ClubMembership
{
UserId = user.Id,
IsActive = true,
IsActive = false,
ActivatedAt = activationDate,
InitialContribution =activationFeeValue,
GiftValue = giftValue, // مقدار از تنظیمات
FirstActivationDate = activationDate,
FirstPackageId = package.Id,
LastActivationDate = activationDate,
LastPackageId = package.Id,
InitialContribution = activationFeeValue,
GiftValue = giftValue,
TotalEarned = 0,
PurchaseMethod = user.PackagePurchaseMethod
};
@@ -174,31 +232,79 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
}
else
{
if (existingMembership.IsActive)
// بررسی آیا چرخه فعال دارد — اگر بله، واقعاً فعال است و نیازی به کار نیست
var hasCurrentCycle = await _context.ClubMembershipCycles
.AnyAsync(c => c.UserId == user.Id && c.IsCurrentCycle, cancellationToken);
// اگر چرخه فعال دارد و درخواست Force نیست — احتمال اجرای تکراری همان خرید
// در حالت ForceActivation (مثل پرداخت دستی)، باید چرخه جدید ساخته شود تا پکیج جدید ثبت شود.
if (hasCurrentCycle && !request.ForceActivation)
{
_logger.LogInformation(
"User {UserId} is already an active club member",
"User {UserId} already has a running cycle — skipping duplicate activation",
user.Id
);
return true;
}
// فعال‌سازی مجدد
// فعال‌سازی مجدد یا خرید مجدد (Q19/Q20) — FirstActivation حفظ میشه (Q21), Last بروزرسانی میشه
// مقدار IsActive قبلی حفظ میشه — اگر قبلاً فعال بوده، فعال بمونه
entity = existingMembership;
entity.IsActive = true;
entity.ActivatedAt = activationDate;
entity.LastActivationDate = activationDate;
entity.LastPackageId = package.Id;
entity.PurchaseMethod = user.PackagePurchaseMethod;
_context.ClubMemberships.Update(entity);
_logger.LogInformation(
"Reactivated club membership for UserId {UserId}",
user.Id
"Re-activating club membership for UserId {UserId} (re-purchase scenario, IsActive was {WasActive})",
user.Id,
existingMembership.IsActive
);
}
await _context.SaveChangesAsync(cancellationToken);
// 6.5. ایجاد ClubMembershipCycle — هر خرید پکیج یک دور جدید
var previousCycles = await _context.ClubMembershipCycles
.Where(c => c.UserId == user.Id && c.IsCurrentCycle)
.ToListAsync(cancellationToken);
foreach (var prevCycle in previousCycles)
{
prevCycle.IsCurrentCycle = false;
ClubMembershipCycleHistoryWriter.WriteCycleClosed(_context, prevCycle, reason: "شروع دوره جدید");
}
var maxCycleNumber = await _context.ClubMembershipCycles
.Where(c => c.ClubMembershipId == entity.Id)
.MaxAsync(c => (int?)c.CycleNumber, cancellationToken) ?? 0;
var newCycle = new ClubMembershipCycle
{
UserId = user.Id,
ClubMembershipId = entity.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);
ClubMembershipCycleHistoryWriter.WriteCycleStarted(_context, newCycle, reason: "فعال‌سازی عضویت باشگاه");
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Created ClubMembershipCycle #{CycleNumber} for UserId {UserId}, MembershipId {MembershipId}",
newCycle.CycleNumber,
user.Id,
entity.Id
);
// 7. ثبت تاریخچه
var history = new ClubMembershipHistory
{
@@ -217,82 +323,62 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
await _context.SaveChangesAsync(cancellationToken);
// ⭐ 8. اضافه کردن مبلغ به Pool هفته جاری
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId, cancellationToken);
if (weeklyPool == null)
// عضو جدید (isNewMembership=true): Pool توسط AcceptClubMembershipContract شارژ می‌شود
// چون کاربر قبل از امضای قرارداد IsActive=false است و قرارداد فقط یک‌بار امضا می‌شود.
// خرید مجدد (isNewMembership=false): قرارداد مجدد امضا نمی‌شود — Pool همین‌جا شارژ می‌شود.
if (!isNewMembership)
{
// ایجاد Pool جدید برای این هفته
weeklyPool = new WeeklyCommissionPool
var currentWeekDefinitionId = GetCurrentWeekDefinitionId();
var weeklyPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(p => p.WeekDefinitionId == currentWeekDefinitionId && p.PackageId == package.Id, cancellationToken);
if (weeklyPool == null)
{
WeekDefinitionId = currentWeekDefinitionId,
TotalPoolAmount = activationFeeValue, // مبلغ هدیه به Pool اضافه میشه
TotalBalances = 0, // در CalculateWeeklyBalances محاسبه میشه
ValuePerBalance = 0, // در CalculateWeeklyCommissionPool محاسبه میشه
IsCalculated = false,
CalculatedAt = null
};
// ایجاد Pool جدید برای این هفته × این پکیج
weeklyPool = new WeeklyCommissionPool
{
WeekDefinitionId = currentWeekDefinitionId,
PackageId = package.Id,
TotalPoolAmount = activationFeeValue,
TotalBalances = 0,
ValuePerBalance = 0,
IsCalculated = false,
CalculatedAt = null
};
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
giftValue
);
}
else
{
// اضافه کردن به Pool موجود
weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
);
}
await _context.SaveChangesAsync(cancellationToken);
// 9. اضافه کردن ویژگی‌های باشگاه برای کاربر (فقط برای عضویت جدید)
if (isNewMembership)
{
var featureIds = ClubFeatureTypeExtensions.GetAllFeatureIds();
var clubFeatures = await _context.ClubFeatures
.Where(f => !f.IsDeleted && featureIds.Contains(f.Id))
.ToListAsync(cancellationToken);
if (clubFeatures.Any())
{
var userClubFeatures = clubFeatures.Select(feature => new UserClubFeature
{
UserId = user.Id,
ClubMembershipId = entity.Id,
ClubFeatureId = feature.Id,
GrantedAt = activationDate,
IsActive = true,
Notes = "اعطا شده به‌طور خودکار هنگام فعالسازی"
}).ToList(); _context.UserClubFeatures.AddRange(userClubFeatures);
await _context.SaveChangesAsync(cancellationToken);
await _context.WeeklyCommissionPools.AddAsync(weeklyPool, cancellationToken);
_logger.LogInformation(
"Granted {Count} club features to UserId {UserId}",
clubFeatures.Count,
user.Id
"Created new WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId} with initial amount: {Amount}",
currentWeekDefinitionId,
activationFeeValue
);
}
else
{
_logger.LogWarning(
"No club features found to grant to UserId {UserId}",
user.Id
weeklyPool.TotalPoolAmount += activationFeeValue;
_context.WeeklyCommissionPools.Update(weeklyPool);
_logger.LogInformation(
"Added {Amount} to existing WeeklyCommissionPool for WeekDefinitionId={WeekDefinitionId}. New total: {NewTotal}",
activationFeeValue,
currentWeekDefinitionId,
weeklyPool.TotalPoolAmount
);
}
await _context.SaveChangesAsync(cancellationToken);
}
else
{
_logger.LogInformation(
"New member — Pool charge deferred to AcceptClubMembershipContract for UserId {UserId}",
user.Id
);
}
// 9. اعمال ویژگی‌های پکیج — DIFF/تفاضل (Q20)
await ApplyFeatureDiffAsync(user.Id, entity.Id, package.Id, isNewMembership, activationDate, cancellationToken);
_logger.LogInformation(
"Club membership activated successfully. UserId: {UserId}, MembershipId: {MembershipId}",
@@ -313,6 +399,74 @@ public class ActivateClubMembershipCommandHandler : IRequestHandler<ActivateClub
}
}
/// <summary>
/// اعمال DIFF فیچرها بر اساس پکیج (Q20)
/// عضویت جدید: همه فیچرهای پکیج اضافه میشه
/// خرید مجدد: تفاضل فیچرهای فعلی و پکیج جدید
/// </summary>
private async Task ApplyFeatureDiffAsync(
long userId, long membershipId, long packageId,
bool isNewMembership, DateTime activationDate,
CancellationToken cancellationToken)
{
// فیچرهای پکیج جدید
var newFeatureIds = await _context.PackageFeatures
.Where(pf => pf.PackageId == packageId && pf.IsIncluded && !pf.IsDeleted)
.Select(pf => pf.ClubFeatureId)
.ToListAsync(cancellationToken);
if (!newFeatureIds.Any())
{
_logger.LogWarning(
"No PackageFeatures found for PackageId {PackageId} — skipping feature assignment for UserId {UserId}",
packageId, userId);
return;
}
// فیچرهای فعال فعلی کاربر
var currentFeatures = await _context.UserClubFeatures
.Where(ucf => ucf.UserId == userId && ucf.IsActive)
.ToListAsync(cancellationToken);
var currentFeatureIds = currentFeatures.Select(f => f.ClubFeatureId).ToHashSet();
// DIFF
var toAdd = newFeatureIds.Where(id => !currentFeatureIds.Contains(id)).ToList();
var toRemove = currentFeatures.Where(f => !newFeatureIds.Contains(f.ClubFeatureId)).ToList();
// حذف فیچرهای قدیمی که پکیج جدید ندارد
foreach (var feature in toRemove)
{
feature.IsActive = false;
feature.Notes = $"حذف شده بابت تغییر پکیج (PackageId: {packageId})";
}
// اضافه فیچرهای جدید
if (toAdd.Any())
{
var newUserFeatures = toAdd.Select(featureId => new UserClubFeature
{
UserId = userId,
ClubMembershipId = membershipId,
ClubFeatureId = featureId,
GrantedAt = activationDate,
IsActive = true,
Notes = isNewMembership
? "اعطا شده هنگام فعالسازی اولیه"
: $"اضافه شده بابت خرید مجدد پکیج (PackageId: {packageId})"
}).ToList();
_context.UserClubFeatures.AddRange(newUserFeatures);
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"Feature DIFF applied for UserId={UserId}: Added={Added}, Removed={Removed}, Kept={Kept}",
userId, toAdd.Count, toRemove.Count,
currentFeatureIds.Count - toRemove.Count);
}
/// <summary>
/// دریافت شناسه تعریف هفته جاری
/// </summary>
@@ -13,6 +13,7 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
{
var query = _context.ClubMemberships
.Include(x => x.User)
.Include(x => x.LastPackage)
.ApplyOrder(sortBy: request.SortBy)
.AsNoTracking()
.AsQueryable();
@@ -35,8 +36,10 @@ public class GetAllClubMembershipsQueryHandler : IRequestHandler<GetAllClubMembe
Id = x.Id,
UserId = x.UserId,
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,
ActivatedAt = x.ActivatedAt,
ActivatedAt = x.LastActivationDate ?? x.ActivatedAt,
InitialContribution = x.InitialContribution,
TotalEarned = x.TotalEarned,
Created = x.Created,
@@ -11,6 +11,8 @@ public class GetAllClubMembershipsResponseModel
public long Id { get; set; }
public long UserId { get; set; }
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 DateTime? ActivatedAt { get; set; }
public long InitialContribution { get; set; }
@@ -1,27 +1,17 @@
namespace CMSMicroservice.Application.ClubMembershipCQ.Queries.GetClubMembershipHistory;
/// <summary>
/// Query برای دریافت تاریخچه تغییرات عضویت باشگاه
/// </summary>
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; }
/// <summary>
/// مرتب‌سازی بر اساس
/// </summary>
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)
{
var query = _context.ClubMembershipHistories
.Include(x => x.ClubMembership)
.ThenInclude(m => m!.User)
.AsNoTracking()
.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.Where(x => x.UserId == request.UserId.Value);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created"); // پیش‌فرض: جدیدترین اول
query = query.ApplyOrder(sortBy: request.SortBy ?? "-Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -36,6 +46,9 @@ public class GetClubMembershipHistoryQueryHandler : IRequestHandler<GetClubMembe
Id = x.Id,
ClubMembershipId = x.ClubMembershipId,
UserId = x.UserId,
UserName = x.ClubMembership != null && x.ClubMembership.User != null
? $"{x.ClubMembership.User.FirstName} {x.ClubMembership.User.LastName}"
: string.Empty,
OldIsActive = x.OldIsActive,
NewIsActive = x.NewIsActive,
OldInitialContribution = x.OldInitialContribution,
@@ -4,31 +4,6 @@ public class GetClubMembershipHistoryQueryValidator : AbstractValidator<GetClubM
{
public GetClubMembershipHistoryQueryValidator()
{
RuleFor(x => x.MembershipId)
.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 باید مقداردهی شود");
// Admin report: filters are all optional
}
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 ClubMembershipId { get; set; }
public long UserId { get; set; }
public string UserName { get; set; } = string.Empty;
public bool OldIsActive { get; set; }
public bool NewIsActive { 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
};
}
}
@@ -43,8 +43,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
// ⭐ دریافت همه کاربرانی که عضو فعال باشگاه هستند
// بدون محدودیت زمانی - همه اعضای فعال کلاب باید کمیسیون بگیرند
// ⚠️ Magic Wallet: کاربرهایی که در حالت جادویی هستند از کمیسیون خارج میشن
var magicModeUserIds = await _context.UserWallets
.Where(w => w.WalletMode == WalletMode.Magic)
.Select(w => w.UserId)
.ToHashSetAsync(cancellationToken);
var activeClubMemberUserIds = await _context.ClubMemberships
.Where(c => c.IsActive)
.Where(c => c.IsActive && !magicModeUserIds.Contains(c.UserId))
.Select(c => c.UserId)
.ToHashSetAsync(cancellationToken);
@@ -76,11 +82,14 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var balancesList = new List<NetworkWeeklyBalance>();
var calculatedAt = DateTime.Now;
// استفاده از SystemConstants (استاتیک - بدون کوئری به دیتابیس)
// سقف تعادل هفتگی برای هر دست (نه کل) - 300 برای چپ + 300 برای راست = حداکثر 600 تعادل
var maxBalancesPerLeg = SystemConstants.CommissionMaxWeeklyBalancesPerLeg;
// حداکثر عمق شبکه برای شمارش اعضا (15 لول)
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
// بارگذاری پکیج پایه برای خواندن سقف‌ها
var package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
// خواندن سقف‌ها از پکیج (قبلاً از SystemConstants بود)
var maxBalancesPerLeg = package.MaxBalancesPerLeg;
var maxNetworkLevel = package.MaxNetworkLevel;
foreach (var user in usersInNetwork.OrderBy(o=>o.Id))
{
@@ -134,6 +143,7 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
{
UserId = user.Id,
WeekDefinitionId = request.WeekDefinitionId,
PackageId = package.Id,
// اطلاعات جدید
LeftLegNewMembers = leftNewMembers,
@@ -256,11 +266,16 @@ public class CalculateWeeklyBalancesCommandHandler : IRequestHandler<CalculateWe
var count = 0;
// اگر فرزند در این هفته فعال شده، 1 امتیاز
var membership = await _context.ClubMemberships
.FirstOrDefaultAsync(x => x.UserId == child.Id && x.IsActive, cancellationToken);
// ⭐ بر اساس بازه تاریخی هفته، نه IsCurrentCycle
// IsCurrentCycle فقط آخرین خرید را نشان می‌دهد — برای تاریخچه‌ای (هفته‌های گذشته)
// باید از PackagePurchasedAt استفاده کرد که تاریخ دقیق خرید را نگه می‌دارد
var hadCycleInWeek = await _context.ClubMembershipCycles
.AnyAsync(x => x.UserId == child.Id
&& x.PackagePurchasedAt >= startDate
&& x.PackagePurchasedAt <= endDate,
cancellationToken);
if (membership?.ActivatedAt >= startDate && membership?.ActivatedAt <= endDate)
if (hadCycleInWeek)
{
count = 1;
}
@@ -10,6 +10,11 @@ public record CalculateWeeklyCommissionPoolCommand : IRequest<long>
/// </summary>
public long WeekDefinitionId { get; init; }
/// <summary>
/// شناسه پکیج — اگر مقدار داشته باشد فقط همین پکیج محاسبه می‌شود.
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// آیا محاسبه مجدد انجام شود؟
/// </summary>
@@ -23,8 +23,17 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
}
// بررسی وجود استخر
var existingPool = await _context.WeeklyCommissionPools
.FirstOrDefaultAsync(x => x.WeekDefinitionId == request.WeekDefinitionId, cancellationToken);
var existingPoolQuery = _context.WeeklyCommissionPools
.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)
{
@@ -40,9 +49,15 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
}
// بررسی وجود تعادل‌های هفتگی
var weeklyBalances = await _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken);
var weeklyBalancesQuery = _context.NetworkWeeklyBalances
.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
if (request.PackageId.HasValue)
{
weeklyBalancesQuery = weeklyBalancesQuery.Where(x => x.PackageId == request.PackageId.Value);
}
var weeklyBalances = await weeklyBalancesQuery.ToListAsync(cancellationToken);
if (!weeklyBalances.Any())
{
@@ -76,9 +91,15 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
// حذف پرداخت‌های قبلی در صورت ForceRecalculate
if (request.ForceRecalculate)
{
var oldPayouts = await _context.UserCommissionPayouts
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId)
.ToListAsync(cancellationToken);
var oldPayoutsQuery = _context.UserCommissionPayouts
.Where(p => p.WeekDefinitionId == request.WeekDefinitionId);
if (request.PackageId.HasValue)
{
oldPayoutsQuery = oldPayoutsQuery.Where(p => p.PackageId == request.PackageId.Value);
}
var oldPayouts = await oldPayoutsQuery.ToListAsync(cancellationToken);
if (oldPayouts.Any())
{
@@ -192,7 +213,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
.ToDictionaryAsync(w => w.UserId, cancellationToken);
var newWallets = new List<UserWallet>();
var walletLogs = new List<UserWalletChangeLog>();
var walletLogs = new List<UserWalletHistory>();
foreach (var payout in payouts)
{
@@ -241,7 +262,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
wallet.NetworkBalance += payout.TotalAmount;
// ایجاد لاگ تغییر کیف پول
var walletLog = new UserWalletChangeLog
var walletLog = new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -258,7 +279,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
}
// ذخیره تغییرات کیف پول و لاگ‌ها
await _context.UserWalletChangeLogs.AddRangeAsync(walletLogs, cancellationToken);
await _context.UserWalletHistories.AddRangeAsync(walletLogs, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
@@ -271,7 +292,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
CancellationToken cancellationToken)
{
// پیدا کردن لاگ‌های کیف پول مرتبط با پرداخت‌های قبلی
var oldWalletLogs = await _context.UserWalletChangeLogs
var oldWalletLogs = await _context.UserWalletHistories
.Where(l => l.RefrenceId.HasValue && oldPayoutIds.Contains(l.RefrenceId.Value))
.ToListAsync(cancellationToken);
@@ -300,7 +321,7 @@ public class CalculateWeeklyCommissionPoolCommandHandler : IRequestHandler<Calcu
}
// حذف لاگ‌های قبلی
_context.UserWalletChangeLogs.RemoveRange(oldWalletLogs);
_context.UserWalletHistories.RemoveRange(oldWalletLogs);
await _context.SaveChangesAsync(cancellationToken);
}
@@ -49,8 +49,11 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
await _context.SaveChangesAsync(cancellationToken);
}
// ⭐ خواندن MaxNetworkLevel از SystemConstants (استاتیک)
var maxNetworkLevel = SystemConstants.CommissionMaxNetworkLevel;
// بارگذاری پکیج پایه برای خواندن سقف‌ها
var package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
var maxNetworkLevel = package.MaxNetworkLevel;
// دریافت همه تعادل‌های هفتگی (شامل صفرها هم برای محاسبه زیرمجموعه)
var allWeeklyBalances = await _context.NetworkWeeklyBalances
@@ -113,6 +116,7 @@ public class ProcessUserPayoutsCommandHandler : IRequestHandler<ProcessUserPayou
UserId = userId,
WeekDefinitionId = request.WeekDefinitionId,
WeeklyPoolId = pool.Id,
PackageId = package.Id,
BalancesEarned = totalBalancesWithSubordinates, // ⭐ شامل زیرمجموعه
ValuePerBalance = pool.ValuePerBalance,
TotalAmount = totalAmount,
@@ -1,3 +1,5 @@
using CMSMicroservice.Application.Common;
namespace CMSMicroservice.Application.CommissionCQ.Commands.RequestWithdrawal;
public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawalCommand, Unit>
@@ -37,7 +39,10 @@ public class RequestWithdrawalCommandHandler : IRequestHandler<RequestWithdrawal
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);
@@ -21,4 +21,9 @@ public record TriggerWeeklyCalculationCommand : IRequest<TriggerWeeklyCalculatio
/// Skip pool calculation and payout processing (Step 2)
/// </summary>
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(
request.WeekDefinitionId,
request.ForceRecalculate,
request.PackageId,
cancellationToken);
steps.Add($"محاسبه امتیازات هفتگی ({balancesCount} کاربر)");
@@ -64,6 +65,7 @@ public class TriggerWeeklyCalculationCommandHandler : IRequestHandler<TriggerWee
var poolId = await strategy.CalculateWeeklyCommissionPoolAsync(
request.WeekDefinitionId,
request.ForceRecalculate,
request.PackageId,
cancellationToken);
steps.Add($"محاسبه استخر و پرداخت کاربران (Pool: {poolId})");
@@ -20,6 +20,11 @@ public record GetAllWeeklyPoolsQuery : IRequest<GetAllWeeklyPoolsResponseDto>
/// </summary>
public bool? OnlyCalculated { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// شماره صفحه
/// </summary>
@@ -17,6 +17,7 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
{
var query = _context.WeeklyCommissionPools
.Include(i=> i.WeekDefinition)
.Include(i => i.Package)
.AsNoTracking();
// Apply filters
@@ -35,6 +36,11 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
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)
query = query.OrderByDescending(x => x.WeekDefinitionId);
@@ -55,7 +61,9 @@ public class GetAllWeeklyPoolsQueryHandler : IRequestHandler<GetAllWeeklyPoolsQu
ValuePerBalance = x.ValuePerBalance,
IsCalculated = x.IsCalculated,
CalculatedAt = x.CalculatedAt,
Created = x.Created
Created = x.Created,
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : string.Empty
})
.ToListAsync(cancellationToken);
@@ -17,6 +17,8 @@ public record WeeklyCommissionPoolDto
public bool IsCalculated { get; init; }
public DateTime? CalculatedAt { get; init; }
public DateTime Created { get; init; }
public long PackageId { get; init; }
public string PackageTitle { get; init; } = string.Empty;
}
public record MetaDataDto
@@ -18,6 +18,11 @@ public record GetMyCommissionPayoutsQuery : IRequest<GetMyCommissionPayoutsRespo
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// Pagination
/// </summary>
@@ -28,6 +28,7 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
var query = _context.UserCommissionPayouts
.Include(x => x.WeekDefinition)
.Include(x => x.Package)
.Where(x => x.UserId == userId)
.AsNoTracking()
.AsQueryable();
@@ -43,6 +44,12 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
}
// فیلتر بر اساس پکیج
if (request.PackageId.HasValue && request.PackageId.Value > 0)
{
query = query.Where(x => x.PackageId == request.PackageId.Value);
}
// مرتب‌سازی: جدیدترین اول
query = query.OrderByDescending(x => x.Created);
@@ -60,7 +67,9 @@ public class GetMyCommissionPayoutsQueryHandler : IRequestHandler<GetMyCommissio
AmountFormatted = x.TotalAmount.ToString("N0") + " تومان",
Status = x.Status,
CalculatedDate = x.PaidAt ?? (DateTime?)x.Created,
DatePersian = ""
DatePersian = "",
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : ""
})
.ToListAsync(cancellationToken);
@@ -20,4 +20,8 @@ public class GetMyCommissionPayoutsResponseModel
public CommissionPayoutStatus Status { get; set; }
public DateTime? CalculatedDate { get; set; }
public string DatePersian { get; set; } = string.Empty;
// Package info
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
}
@@ -17,6 +17,11 @@ public record GetMyWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesResponseD
/// </summary>
public bool OnlyActive { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// Pagination
/// </summary>
@@ -38,6 +38,7 @@ public class GetMyWeeklyBalancesQueryHandler : IRequestHandler<GetMyWeeklyBalanc
{
UserId = userId,
WeekDefinitionId = request.WeekDefinitionId,
PackageId = request.PackageId,
OnlyActive = request.OnlyActive,
PaginationState = request.PaginationState
};
@@ -20,6 +20,11 @@ public record GetUserCommissionPayoutsQuery : IRequest<GetUserCommissionPayoutsR
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// مرتب‌سازی
/// </summary>
@@ -18,6 +18,7 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
var query = _context.UserCommissionPayouts
.Include(x => x.WeekDefinition)
.Include(x => x.User)
.Include(x => x.Package)
.AsNoTracking()
.AsQueryable();
@@ -41,6 +42,12 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId);
}
// فیلتر بر اساس پکیج
if (request.PackageId.HasValue && request.PackageId.Value > 0)
{
query = query.Where(x => x.PackageId == request.PackageId.Value);
}
query = query.ApplyOrder(sortBy: request.SortBy ?? "Created");
var meta = await query.GetMetaData(request.PaginationState, cancellationToken);
@@ -64,7 +71,9 @@ public class GetUserCommissionPayoutsQueryHandler : IRequestHandler<GetUserCommi
WithdrawalMethod = x.WithdrawalMethod,
IbanNumber = x.IbanNumber,
WithdrawnAt = x.WithdrawnAt,
Created = x.Created
Created = x.Created,
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : ""
})
.ToListAsync(cancellationToken);
@@ -24,4 +24,8 @@ public class GetUserCommissionPayoutsResponseModel
public string? IbanNumber { get; set; }
public DateTime? WithdrawnAt { get; set; }
public DateTimeOffset Created { get; set; }
// Package info
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
}
@@ -15,6 +15,10 @@ public record GetUserWeeklyBalancesQuery : IRequest<GetUserWeeklyBalancesRespons
/// </summary>
public long? WeekDefinitionId { get; init; }
/// <summary>
/// فیلتر بر اساس پکیج (اختیاری)
/// </summary>
public long? PackageId { get; init; }
/// <summary>
/// فقط موارد Expired نشده؟
@@ -15,66 +15,49 @@ public class GetUserWeeklyBalancesQueryHandler : IRequestHandler<GetUserWeeklyBa
public async Task<GetUserWeeklyBalancesResponseDto> Handle(GetUserWeeklyBalancesQuery request, CancellationToken cancellationToken)
{
var query = _context.NetworkWeeklyBalances
.Include(x => x.WeekDefinition)
.Include(x => x.User)
.AsNoTracking()
.AsQueryable();
var pageSize = request.PaginationState?.PageSize > 0 ? request.PaginationState.PageSize : PaginationDefaults.PageSize;
var pageNumber = request.PaginationState?.PageNumber > 0 ? request.PaginationState.PageNumber : PaginationDefaults.PageNumber;
// UserId > 0 → filter by that user
// UserId == 0 or null → show ALL users (admin mode)
// Customer endpoints resolve UserId from JWT before calling this handler
long? userId = request.UserId;
// تشخیص جهت مرتب‌سازی از SortBy (پیش‌فرض: نزولی)
var sortBy = request.SortBy ?? "-WeekDefinitionId";
int sortDescending = sortBy.StartsWith("-") ? 1 : 0;
if (userId.HasValue && userId.Value > 0)
{
query = query.Where(x => x.UserId == userId.Value);
}
long? userId = request.UserId.HasValue && request.UserId.Value > 0 ? request.UserId : null;
long? weekDefinitionId = request.WeekDefinitionId.HasValue ? request.WeekDefinitionId : null;
long? packageId = request.PackageId.HasValue && request.PackageId.Value > 0 ? request.PackageId : null;
int onlyActive = request.OnlyActive.HasValue && request.OnlyActive.Value ? 1 : 0;
// فیلتر بر اساس WeekDefinitionId (روش ترجیحی)
if (request.WeekDefinitionId.HasValue)
{
query = query.Where(x => x.WeekDefinitionId == request.WeekDefinitionId.Value);
}
if (request.OnlyActive.HasValue && request.OnlyActive.Value)
{
query = query.Where(x => !x.IsExpired);
}
// مرتب‌سازی بر اساس 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
})
var rows = await _context.Database
.SqlQuery<GetUserWeeklyBalancesResponseModel>(
$"""
EXEC [CMS].[sp_GetUserWeeklyBalances]
@UserId = {userId},
@WeekDefinitionId = {weekDefinitionId},
@PackageId = {packageId},
@OnlyActive = {onlyActive},
@SortDescending = {sortDescending},
@PageNumber = {pageNumber},
@PageSize = {pageSize}
""")
.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
{
MetaData = meta,
Models = models
Models = rows
};
}
}
@@ -29,4 +29,11 @@ public class GetUserWeeklyBalancesResponseModel
public DateTime? CalculatedAt { get; set; }
public bool IsExpired { get; set; }
public DateTimeOffset Created { get; set; }
// Package info
public long PackageId { get; set; }
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>
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)
{
var pool = await _context.WeeklyCommissionPools
var query = _context.WeeklyCommissionPools
.Include(x => x.WeekDefinition)
.Include(x => x.Package)
.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
{
Id = x.Id,
WeekDefinitionId = x.WeekDefinitionId,
WeekDisplayName=x.WeekDefinition.DisplayName,
WeekDisplayName = x.WeekDefinition.DisplayName,
TotalPoolAmount = x.TotalPoolAmount,
TotalBalances = x.TotalBalances,
ValuePerBalance = x.ValuePerBalance,
IsCalculated = x.IsCalculated,
CalculatedAt = x.CalculatedAt,
Created = x.Created
Created = x.Created,
PackageId = x.PackageId,
PackageTitle = x.Package != null ? x.Package.Title : string.Empty
})
.FirstOrDefaultAsync(cancellationToken);
// if (pool != null)
// {
// pool.WeekDisplayName = _weekDefinitionRepository.GetDisplayNameByGregorianWeekNumber(pool.WeekNumber);
// }
pool ??= new WeeklyCommissionPoolDto();
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 DateTime? CalculatedAt { 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; }
}
@@ -51,6 +51,15 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
.PaginatedListAsync(paginationState: request.PaginationState)
.ToListAsync(cancellationToken);
// دریافت PackageName از آخرین خرید پکیج کاربران
var userIds = models.Select(x => x.UserId).Distinct().ToList();
var userPackageNames = await _context.UserPackagePurchases
.Where(p => userIds.Contains(p.UserId))
.Include(p => p.Package)
.GroupBy(p => p.UserId)
.Select(g => new { UserId = g.Key, PackageName = g.OrderByDescending(x => x.PurchasedAt).First().Package.Title })
.ToDictionaryAsync(x => x.UserId, x => x.PackageName, cancellationToken);
var result = models.Select(x => new WithdrawalRequestModel
{
Id = x.Id,
@@ -69,7 +78,8 @@ public class GetWithdrawalRequestsQueryHandler : IRequestHandler<GetWithdrawalRe
BankReferenceId = x.BankReferenceId,
BankTrackingCode = x.BankTrackingCode,
PaymentFailureReason = x.PaymentFailureReason,
Created = x.Created
Created = x.Created,
PackageName = userPackageNames.GetValueOrDefault(x.UserId)
}).ToList();
return new GetWithdrawalRequestsResponseDto
@@ -25,4 +25,5 @@ public class WithdrawalRequestModel
public string? BankTrackingCode { get; set; }
public string? PaymentFailureReason { get; set; }
public DateTime Created { get; set; }
public string? PackageName { 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 CMSMicroservice.Domain.Common;
@@ -8,16 +9,46 @@ public static class SortByExtensions
public static IQueryable<TSource> ApplyOrder<TSource>(this IQueryable<TSource> source,
string? sortBy) where TSource : BaseAuditableEntity
{
// default sort approach
if (sortBy is null or "")
{
source = source.OrderByDescending(p => p.Created);
return source;
return source.OrderByDescending(p => p.Created);
}
// sort using dynamic linq
source = source.OrderBy(sortBy);
return source.OrderBy(NormalizeSortBy(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;
}
@@ -34,14 +34,16 @@ public interface IUserNotificationService
Task SendCommissionReceivedNotificationAsync(
long userId,
decimal amount,
int weekNumber,
int weekNumber,
string? packageName = null,
CancellationToken cancellationToken = default);
/// <summary>
/// ارسال اعلان فعال‌سازی عضویت باشگاه
/// </summary>
Task SendClubActivationNotificationAsync(
long userId,
long userId,
string? packageName = null,
CancellationToken cancellationToken = default);
/// <summary>
@@ -49,6 +51,7 @@ public interface IUserNotificationService
/// </summary>
Task SendPayoutErrorNotificationAsync(
long userId,
string errorMessage,
string errorMessage,
string? packageName = null,
CancellationToken cancellationToken = default);
}
@@ -32,12 +32,16 @@ public interface IApplicationDbContext
DbSet<OrderVAT> OrderVATs { get; }
DbSet<UserPackagePurchase> UserPackagePurchases { get; }
DbSet<UserWallet> UserWallets { get; }
DbSet<UserWalletChangeLog> UserWalletChangeLogs { get; }
DbSet<UserWalletHistory> UserWalletHistories { get; }
DbSet<ManualPayment> ManualPayments { get; }
DbSet<PaymentTransaction> PaymentTransactions { get; }
DbSet<PublicMessage> PublicMessages { get; }
DbSet<ClubMembership> ClubMemberships { get; }
DbSet<ClubMembershipHistory> ClubMembershipHistories { get; }
DbSet<ClubMembershipCycle> ClubMembershipCycles { get; }
DbSet<ClubMembershipCycleHistory> ClubMembershipCycleHistories { get; }
DbSet<PackageHistory> PackageHistories { get; }
DbSet<PackageFeature> PackageFeatures { get; }
DbSet<ClubFeature> ClubFeatures { get; }
DbSet<UserClubFeature> UserClubFeatures { get; }
DbSet<NetworkWeeklyBalance> NetworkWeeklyBalances { get; }
@@ -80,6 +84,10 @@ public interface IApplicationDbContext
DbSet<SitePage> SitePages { get; }
DbSet<SitePageSection> SitePageSections { get; }
// ============= Site Page Settings (Simplified) =============
DbSet<SitePageSettings> SitePageSettingsEntities { get; }
DbSet<SitePageImage> SitePageImages { get; }
/// <summary>
/// دسترسی به DatabaseFacade برای اجرای raw SQL و Stored Procedures
/// </summary>
@@ -11,11 +11,13 @@ public interface ICommissionCalculationStrategy
/// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="packageId">اگر مقدار داشته باشد فقط این پکیج محاسبه می‌شود — در غیر این صورت همه پکیج‌ها</param>
/// <param name="cancellationToken">توکن لغو</param>
/// <returns>تعداد رکوردهای محاسبه شده</returns>
Task<int> CalculateWeeklyBalancesAsync(
long weekDefinitionId,
bool forceRecalculate,
bool forceRecalculate,
long? packageId = null,
CancellationToken cancellationToken = default);
/// <summary>
@@ -23,11 +25,13 @@ public interface ICommissionCalculationStrategy
/// </summary>
/// <param name="weekDefinitionId">شناسه هفته</param>
/// <param name="forceRecalculate">محاسبه مجدد حتی اگر قبلاً محاسبه شده</param>
/// <param name="packageId">اگر مقدار داشته باشد فقط این پکیج محاسبه می‌شود — در غیر این صورت همه پکیج‌ها</param>
/// <param name="cancellationToken">توکن لغو</param>
/// <returns>شناسه Pool ایجاد شده</returns>
Task<long> CalculateWeeklyCommissionPoolAsync(
long weekDefinitionId,
bool forceRecalculate,
bool forceRecalculate,
long? packageId = null,
CancellationToken cancellationToken = default);
}
@@ -13,5 +13,10 @@ public interface IKavenegarService
/// <summary>
/// ارسال پیامک با قالب (VerifyLookup)
/// </summary>
Task VerifyLookupAsync(string mobile, string token, string template = "Afrino");
Task VerifyLookupAsync(string mobile, string token, string template = "OTP");
/// <summary>
/// ارسال پیامک با قالب و توکن دوم (VerifyLookup) — مثلاً برای قراردادها
/// </summary>
Task VerifyLookupAsync(string mobile, string token, string token2, string template);
}
@@ -41,10 +41,23 @@ public interface IPaymentGatewayService
decimal amountInToman,
CancellationToken cancellationToken = default)
{
// پیش‌فرض: درگاه‌هایی که Amount نمی‌خواهند، از overload بدون amount استفاده کنند
return VerifyPaymentAsync(refId, verificationToken, cancellationToken);
// ⚠️ هشدار: این پیاده‌سازی پیش‌فرض مبلغ را نادیده می‌گیرد.
// درگاه‌هایی مثل زرین‌پال باید حتماً این متد را override کنند.
throw new NotImplementedException(
"درگاه پرداخت باید متد 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>
@@ -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
}
@@ -1,16 +0,0 @@
using CMSMicroservice.Application.UserWalletChangeLogCQ.Queries.GetAllUserWalletChangeLogByFilter;
using CMSMicroservice.Application.UserWalletChangeLogCQ.Queries.GetUserWalletChangeLog;
namespace CMSMicroservice.Application.Common.Mappings;
public class UserWalletChangeLogProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
config.NewConfig<UserWalletChangeLog,GetAllUserWalletChangeLogByFilterResponseModel>()
.Map(dest => dest.CreatedAt, src => src.Created);
config.NewConfig<UserWalletChangeLog, GetUserWalletChangeLogResponseDto>()
.Map(dest => dest.CreatedAt, src => src.Created);
}
}
@@ -0,0 +1,16 @@
using CMSMicroservice.Application.UserWalletHistoryCQ.Queries.GetAllUserWalletHistoryByFilter;
using CMSMicroservice.Application.UserWalletHistoryCQ.Queries.GetUserWalletHistory;
namespace CMSMicroservice.Application.Common.Mappings;
public class UserWalletHistoryProfile : IRegister
{
void IRegister.Register(TypeAdapterConfig config)
{
config.NewConfig<UserWalletHistory,GetAllUserWalletHistoryByFilterResponseModel>()
.Map(dest => dest.CreatedAt, src => src.Created);
config.NewConfig<UserWalletHistory, GetUserWalletHistoryResponseDto>()
.Map(dest => dest.CreatedAt, src => src.Created);
}
}
@@ -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()}";
}
}
@@ -6,14 +6,14 @@ namespace CMSMicroservice.Application.Common.Services;
public static class VatCalculator
{
/// <summary>
/// نرخ VAT ایران - 9 درصد
/// نرخ VAT ایران - 10 درصد
/// </summary>
public const decimal VAT_RATE = 0.09m;
public const decimal VAT_RATE = 0.10m;
/// <summary>
/// نرخ VAT به صورت درصد (9)
/// نرخ VAT به صورت درصد (10)
/// </summary>
public const int VAT_PERCENT = 9;
public const int VAT_PERCENT = 10;
/// <summary>
/// محاسبه VAT از مبلغ خالص
@@ -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);
}
}
@@ -12,7 +12,7 @@ namespace CMSMicroservice.Application.DayaLoanCQ.Commands.CheckAndProcessDayaLoa
/// 1. استعلام از API دایا
/// 2. ذخیره/به‌روزرسانی DayaLoanContract
/// 3. شارژ کیف پول برای وام‌های تأیید شده
/// 4. ثبت Order پکیج طلایی
/// 4. ثبت Order پکیج
/// 5. ارسال SMS
/// </summary>
public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndProcessDayaLoansCommand, CheckAndProcessDayaLoansResponseDto>
@@ -72,6 +72,17 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
result.UserId = user.Id;
// 2.5. بررسی محدودیت دایا — پس از اولین دور، فقط IPG مجاز است
var hasPreviousCycle = await _context.ClubMembershipCycles
.AnyAsync(c => c.UserId == user.Id, cancellationToken);
if (hasPreviousCycle)
{
result.Message = "کاربرانی که دور اول را تکمیل کرده‌اند فقط از طریق درگاه بانکی می‌توانند پکیج خریداری کنند";
results.Add(result);
continue;
}
// 3. ذخیره/به‌روزرسانی DayaLoanContract
var contract = await _context.DayaLoanContracts
.FirstOrDefaultAsync(d => d.NationalCode == dayaResult.NationalCode, cancellationToken);
@@ -111,6 +122,7 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
contract.IsProcessed = true;
result.WasProcessed = true;
result.NewWalletBalance = processResult.NewBalance;
result.PackageName = processResult.PackageName;
result.Message = "وام پردازش و کیف پول شارژ شد";
processedCount++;
@@ -170,15 +182,20 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
/// <summary>
/// پردازش مالی وام دایا
/// </summary>
private async Task<(long NewBalance, long TransactionId)> ProcessDayaLoanAsync(
private async Task<(long NewBalance, long TransactionId, string PackageName)> ProcessDayaLoanAsync(
User user,
string contractNumber,
CancellationToken cancellationToken)
{
// بارگذاری پکیج پایه
var package = await _context.Packages
.FirstOrDefaultAsync(p => p.IsBasePackage && !p.IsDeleted, cancellationToken)
?? throw new InvalidOperationException("پکیج پایه یافت نشد");
// 1. ایجاد تراکنش
var transaction = new Transaction
{
Amount = SystemConstants.DayaLoanAmount,
Amount = package.Price,
Description = $"دریافت اعتبار دایا - قرارداد {contractNumber}",
PaymentStatus = PaymentStatus.Success,
PaymentDate = DateTime.Now,
@@ -205,25 +222,26 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
}
// 3. شارژ کیف پول عادی
wallet.Balance += SystemConstants.DayaLoanAmount;
wallet.Balance += package.Price;
var mainLog = new UserWalletChangeLog
var mainLog = new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = 0,
ChangeValue = SystemConstants.DayaLoanAmount,
ChangeValue = package.Price,
CurrentNetworkBalance = wallet.NetworkBalance,
ChangeNerworkValue = 0,
IsIncrease = true,
RefrenceId = transaction.Id
RefrenceId = transaction.Id,
PackageId = package.Id
};
await _context.UserWalletChangeLogs.AddAsync(mainLog, cancellationToken);
await _context.UserWalletHistories.AddAsync(mainLog, cancellationToken);
// 4. شارژ کیف پول تخفیف (دو برابر)
var discountAmount = SystemConstants.DayaLoanAmount * 2;
// 4. شارژ کیف پول تخفیف
var discountAmount = (long)(package.Price * package.DiscountMultiplier);
wallet.DiscountBalance += discountAmount;
var discountLog = new UserWalletChangeLog
var discountLog = new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -233,36 +251,28 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
CurrentDiscountBalance = 0,
ChangeDiscountValue = discountAmount,
IsIncrease = true,
RefrenceId = transaction.Id
RefrenceId = transaction.Id,
PackageId = package.Id
};
await _context.UserWalletChangeLogs.AddAsync(discountLog, cancellationToken);
await _context.UserWalletHistories.AddAsync(discountLog, cancellationToken);
// 5. به‌روزرسانی کاربر
user.HasReceivedDayaCredit = true;
user.DayaCreditReceivedAt = DateTime.Now;
user.PackagePurchaseMethod = PackagePurchaseMethod.DayaLoan;
// 6. ثبت Order پکیج پایه
var goldenPackage = await _context.Packages
.FirstOrDefaultAsync(p => p.Id == 4, cancellationToken);
if (goldenPackage != null)
// 6. ثبت UserPackagePurchase برای پکیج پایه
var packagePurchase = new UserPackagePurchase
{
// 6. ثبت UserPackagePurchase برای پکیج پایه
var goldenPackageId =goldenPackage.Id;
var packagePurchase = new UserPackagePurchase
{
UserId = user.Id,
PackageId = goldenPackageId,
PurchaseMethod = PackagePurchaseMethod.DayaLoan,
PurchasedAt = DateTime.Now,
Amount = SystemConstants.DayaLoanAmount,
TransactionId = transaction.Id
};
UserId = user.Id,
PackageId = package.Id,
PurchaseMethod = PackagePurchaseMethod.DayaLoan,
PurchasedAt = DateTime.Now,
Amount = package.Price,
TransactionId = transaction.Id
};
await _context.UserPackagePurchases.AddAsync(packagePurchase, cancellationToken);
}
await _context.UserPackagePurchases.AddAsync(packagePurchase, cancellationToken);
// 7. Domain Event
user.AddDomainEvent(new DayaLoanApprovedEvent(user, transaction, contractNumber));
@@ -270,25 +280,25 @@ public class CheckAndProcessDayaLoansCommandHandler : IRequestHandler<CheckAndPr
await _context.SaveChangesAsync(cancellationToken);
// 8. ارسال SMS
await SendDayaLoanSmsAsync(user);
await SendDayaLoanSmsAsync(user, package.Title);
return (wallet.Balance, transaction.Id);
return (wallet.Balance, transaction.Id, package.Title);
}
/// <summary>
/// ارسال SMS اطلاع‌رسانی
/// </summary>
private async Task SendDayaLoanSmsAsync(User user)
private async Task SendDayaLoanSmsAsync(User user, string packageName)
{
try
{
var userName = SmsTemplates.GetUserName(user.FirstName+" "+user.LastName);
var message = SmsTemplates.DayaLoanReceived(userName);
var message = SmsTemplates.DayaLoanReceived(userName, packageName);
await _smsService.SendAsync(user.Mobile, message);
// ارسال SMS به ادمین برای اطلاع
var adminMessage = $"وام دایا دریافت شد\nکاربر: {user.FirstName} {user.LastName}\nموبایل: {user.Mobile}\nکدملی: {user.NationalCode}";
var adminMessage = $"وام دایا دریافت شد\nکاربر: {user.FirstName} {user.LastName}\nموبایل: {user.Mobile}\nکدملی: {user.NationalCode}\nپکیج: {packageName}";
await _smsService.SendAsync("09199877503", adminMessage);
_logger.LogInformation("Daya loan SMS sent to User {UserId}", user.Id);
@@ -63,6 +63,11 @@ public class DayaLoanProcessResult
/// </summary>
public long? NewWalletBalance { get; set; }
/// <summary>
/// نام پکیج
/// </summary>
public string? PackageName { get; set; }
/// <summary>
/// پیام
/// </summary>
@@ -62,9 +62,23 @@ public class CompleteOrderPaymentCommandHandler : IRequestHandler<CompleteOrderP
var userWallet = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == order.UserId, cancellationToken);
if (userWallet != null)
if (userWallet != null && order.DiscountBalanceUsed > 0)
{
userWallet.DiscountBalance -= order.DiscountBalanceUsed;
// ثبت لاگ تغییرات کیف پول
_context.UserWalletHistories.Add(new Domain.Entities.UserWalletHistory
{
WalletId = userWallet.Id,
CurrentBalance = userWallet.Balance,
ChangeValue = 0,
CurrentNetworkBalance = userWallet.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = userWallet.DiscountBalance,
ChangeDiscountValue = -order.DiscountBalanceUsed,
IsIncrease = false,
RefrenceId = transaction.Id
});
}
// تایید فروش و کسر موجودی از طریق InventoryService
@@ -1,3 +1,5 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Services;
using CMSMicroservice.Domain.Entities.DiscountShop;
@@ -17,22 +19,32 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
private readonly IPaymentGatewayService _paymentGateway;
private readonly IConfiguration _configuration;
private readonly ILogger<PlaceOrderCommandHandler> _logger;
private readonly IUserPaymentLock _paymentLock;
public PlaceOrderCommandHandler(
IApplicationDbContext context,
IInventoryService inventoryService,
IPaymentGatewayService paymentGateway,
IConfiguration configuration,
ILogger<PlaceOrderCommandHandler> logger)
ILogger<PlaceOrderCommandHandler> logger,
IUserPaymentLock paymentLock)
{
_context = context;
_inventoryService = inventoryService;
_paymentGateway = paymentGateway;
_configuration = configuration;
_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
var userWallet = await _context.UserWallets
@@ -111,7 +123,18 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
// Always apply maximum possible discount (100%) — user cannot choose less
var maxDiscountBalanceUsable = totalDiscountAmount;
var actualDiscountBalanceUsed = Math.Min(maxDiscountBalanceUsable, userWallet.DiscountBalance);
// بررسی کافی بودن موجودی کیف پول اعتباری (تخفیفی)
if (userWallet.DiscountBalance < maxDiscountBalanceUsable)
{
return new PlaceOrderResponseDto
{
Success = false,
Message = $"موجودی کیف پول اعتباری کافی نیست. مبلغ مورد نیاز: {maxDiscountBalanceUsable:N0} تومان، موجودی شما: {userWallet.DiscountBalance:N0} تومان"
};
}
var actualDiscountBalanceUsed = maxDiscountBalanceUsable;
_logger.LogInformation(
"Discount auto-applied: max allowed={MaxAllowed}, wallet balance={WalletBalance}, used={Used}",
@@ -184,9 +207,9 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
{
try
{
// آدرس callback — زرین‌پال بعد از پرداخت کاربر را به اینجا هدایت می‌کند
var cmsBaseUrl = _configuration["CmsBaseUrl"] ?? "https://localhost:32846";
var callbackUrl = $"{cmsBaseUrl}/api/payment/discount-order/callback?orderId={order.Id}";
// آدرس callback — زرین‌پال بعد از پرداخت مستقیم به فرانت‌آفیس هدایت می‌کند
var frontOfficeBaseUrl = _configuration["FrontOfficeBaseUrl"] ?? "https://localhost:5268";
var callbackUrl = $"{frontOfficeBaseUrl}/profile/payment-callback?type=discount-order&orderId={order.Id}";
// درخواست به درگاه
var paymentResult = await _paymentGateway.InitiatePaymentAsync(new PaymentRequest
@@ -263,9 +286,25 @@ public class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, Place
var walletForDeduct = await _context.UserWallets
.FirstOrDefaultAsync(w => w.UserId == request.UserId, cancellationToken);
if (walletForDeduct != null)
if (walletForDeduct != null && actualDiscountBalanceUsed > 0)
{
walletForDeduct.DiscountBalance -= actualDiscountBalanceUsed;
// ثبت لاگ تغییرات کیف پول
_context.UserWalletHistories.Add(new Domain.Entities.UserWalletHistory
{
WalletId = walletForDeduct.Id,
CurrentBalance = walletForDeduct.Balance,
ChangeValue = 0,
CurrentNetworkBalance = walletForDeduct.NetworkBalance,
ChangeNerworkValue = 0,
CurrentDiscountBalance = walletForDeduct.DiscountBalance,
ChangeDiscountValue = -actualDiscountBalanceUsed,
IsIncrease = false,
RefrenceId = transaction.Id
});
}
foreach (var cartItem in cartItems)
{
await _inventoryService.ConfirmSaleAsync(
@@ -35,6 +35,11 @@ public class UpdateOrderStatusCommandHandler : IRequestHandler<UpdateOrderStatus
order.TrackingCode = request.TrackingCode;
}
if (!string.IsNullOrEmpty(request.AdminNotes))
{
order.DeliveryDescription = request.AdminNotes;
}
await _context.SaveChangesAsync(cancellationToken);
return new UpdateOrderStatusResponseDto
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using MediatR;
@@ -80,35 +81,58 @@ public class GetAllDiscountOrdersQueryHandler : IRequestHandler<GetAllDiscountOr
// Apply pagination
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 20 };
var orders = await query
var rows = await query
.OrderByDescending(o => o.Created)
.Skip((pagination.PageNumber - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.Select(o => new AdminOrderDto
.Select(o => new
{
Id = o.Id,
UserId = o.UserId,
UserFullName = (o.User.FirstName ?? "") + " " + (o.User.LastName ?? ""),
UserMobile = o.User.Mobile,
TotalAmount = o.TotalAmount,
DiscountBalanceUsed = o.DiscountBalanceUsed,
GatewayAmountPaid = o.GatewayAmountPaid,
VatAmount = o.VatAmount,
PaymentStatus = o.PaymentStatus,
PaymentDate = o.PaymentDate,
DeliveryStatus = o.DeliveryStatus,
DeliveryDate = null, // TODO: Add DeliveryDate to DiscountOrder if needed
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,
ReceiverMobile = o.User.Mobile,
TrackingCode = o.TrackingCode,
o.TrackingCode,
AdminNote = o.DeliveryDescription,
Created = o.Created,
LastModified = o.LastModified,
o.Created,
o.LastModified,
ItemsCount = o.OrderDetails.Count
})
.ToListAsync(cancellationToken);
var orders = rows.Select(o => new AdminOrderDto
{
Id = o.Id,
UserId = o.UserId,
UserFullName = UserDisplayName.From(o.FirstName, o.LastName, o.Mobile, o.UserId),
UserMobile = o.Mobile,
TotalAmount = o.TotalAmount,
DiscountBalanceUsed = o.DiscountBalanceUsed,
GatewayAmountPaid = o.GatewayAmountPaid,
VatAmount = o.VatAmount,
PaymentStatus = o.PaymentStatus,
PaymentDate = o.PaymentDate,
DeliveryStatus = o.DeliveryStatus,
DeliveryDate = null,
ShippingAddress = o.ShippingAddress,
ReceiverName = o.ReceiverName,
ReceiverMobile = o.Mobile,
TrackingCode = o.TrackingCode,
AdminNote = o.AdminNote,
Created = o.Created,
LastModified = o.LastModified,
ItemsCount = o.ItemsCount
}).ToList();
return new GetAllDiscountOrdersResponseDto
{
MetaData = new MetaData
@@ -8,9 +8,14 @@ public class GetDiscountProductsQuery : IRequest<GetDiscountProductsResponseDto>
public PaginationState? PaginationQuery { get; set; }
public long? CategoryId { 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 int? MinPrice { get; set; }
public int? MaxPrice { get; set; }
public string? SortBy { get; set; }
}
public class GetDiscountProductsResponseDto
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.Common.Extensions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Application.Common.Models;
using MediatR;
@@ -41,6 +42,11 @@ public class GetDiscountProductsQueryHandler : IRequestHandler<GetDiscountProduc
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)
{
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.ApplyOrderWithInStockPriority(request.SortBy, p => p.RemainingCount > 0);
var totalCount = await query.CountAsync(cancellationToken);
// Apply pagination
var pagination = request.PaginationQuery ?? new PaginationState { PageNumber = 1, PageSize = 10 };
var products = await query
.OrderByDescending(p => p.Created)
.Skip((pagination.PageNumber - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.Select(p => new DiscountProductDto
@@ -6,7 +6,7 @@ namespace CMSMicroservice.Application.DiscountShopCQ.Queries.GetOrderById;
public class GetOrderByIdQuery : IRequest<OrderDetailDto?>
{
public long OrderId { get; set; }
public long UserId { get; set; }
public long? UserId { get; set; }
}
public class OrderDetailDto
@@ -32,6 +32,8 @@ public class UserAddressDto
public string Title { get; set; }
public string Address { get; set; }
public string PostalCode { get; set; }
/// <summary>شماره تماس سفارش‌دهنده (موبایل کاربر) برای برچسب پست</summary>
public string? Phone { get; set; }
}
public class OrderItemDto
@@ -15,9 +15,17 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
public async Task<OrderDetailDto?> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
{
var order = await _context.DiscountOrders
.Where(o => o.Id == request.OrderId && o.UserId == request.UserId)
var query = _context.DiscountOrders
.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.User)
.Include(o => o.OrderDetails)
.ThenInclude(od => od.Product)
.FirstOrDefaultAsync(cancellationToken);
@@ -43,7 +51,8 @@ public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, Order
{
Title = order.UserAddress.Title,
Address = order.UserAddress.Address,
PostalCode = order.UserAddress.PostalCode
PostalCode = order.UserAddress.PostalCode,
Phone = order.User?.Mobile
},
Items = order.OrderDetails.Select(od => new OrderItemDto
{
@@ -0,0 +1,36 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.DeletePageImage;
// ── Command ──
public class DeletePageImageCommand : IRequest<Unit>
{
public long Id { get; set; }
}
// ── Handler ──
public class DeletePageImageCommandHandler : IRequestHandler<DeletePageImageCommand, Unit>
{
private readonly IApplicationDbContext _context;
public DeletePageImageCommandHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(DeletePageImageCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SitePageImages
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken);
if (entity != null)
{
entity.IsDeleted = true; // Soft delete
await _context.SaveChangesAsync(cancellationToken);
}
return Unit.Value;
}
}
@@ -0,0 +1,96 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageImage;
// ── Command ──
public class SavePageImageCommand : IRequest<SavePageImageResult>
{
public long Id { get; set; } // 0 = ایجاد جدید
public long SitePageSettingsId { get; set; }
public string ImageGroup { get; set; } = default!;
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Description { get; set; }
public string? LinkUrl { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
// Image upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
public class SavePageImageResult
{
public long Id { get; set; }
public bool Success { get; set; }
}
// ── Handler ──
public class SavePageImageCommandHandler : IRequestHandler<SavePageImageCommand, SavePageImageResult>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public SavePageImageCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<SavePageImageResult> Handle(SavePageImageCommand request, CancellationToken cancellationToken)
{
SitePageImage entity;
if (request.Id > 0)
{
// ویرایش
entity = await _context.SitePageImages
.FirstOrDefaultAsync(x => x.Id == request.Id && !x.IsDeleted, cancellationToken)
?? throw new Exception($"تصویر با شناسه {request.Id} یافت نشد");
}
else
{
// ایجاد جدید
entity = new SitePageImage
{
SitePageSettingsId = request.SitePageSettingsId,
ImageGroup = request.ImageGroup,
ImagePath = string.Empty // placeholder — will be set below
};
_context.SitePageImages.Add(entity);
}
entity.Title = request.Title;
entity.Subtitle = request.Subtitle;
entity.Description = request.Description;
entity.LinkUrl = request.LinkUrl;
entity.IconName = request.IconName;
entity.SortOrder = request.SortOrder;
entity.IsActive = request.IsActive;
// آپلود تصویر
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
$"Images/SitePageSettings/{request.ImageGroup}",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
entity.ImagePath = result.Main.Path;
entity.ThumbnailPath = result.Thumbnail.Path;
}
await _context.SaveChangesAsync(cancellationToken);
return new SavePageImageResult { Id = entity.Id, Success = true };
}
}
@@ -0,0 +1,83 @@
using CMSMicroservice.Application.Common.FileManager;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageSettings;
// ── Command ──
public class SavePageSettingsCommand : IRequest<SavePageSettingsResult>
{
public string PageKey { get; set; } = default!;
public string Title { get; set; } = default!;
public string? MetaDescription { get; set; }
public string? HeroTitle { get; set; }
public string? HeroSubtitle { get; set; }
public bool IsActive { get; set; }
public string? SettingsJson { get; set; }
// Hero image upload
public byte[]? ImageFileBytes { get; set; }
public string? ImageFileMime { get; set; }
public string? ImageFileName { get; set; }
}
public class SavePageSettingsResult
{
public long Id { get; set; }
public bool Success { get; set; }
}
// ── Handler ──
public class SavePageSettingsCommandHandler : IRequestHandler<SavePageSettingsCommand, SavePageSettingsResult>
{
private readonly IApplicationDbContext _context;
private readonly IFileManager _fileManager;
public SavePageSettingsCommandHandler(IApplicationDbContext context, IFileManager fileManager)
{
_context = context;
_fileManager = fileManager;
}
public async Task<SavePageSettingsResult> Handle(SavePageSettingsCommand request, CancellationToken cancellationToken)
{
var entity = await _context.SitePageSettingsEntities
.FirstOrDefaultAsync(x => x.PageKey == request.PageKey && !x.IsDeleted, cancellationToken);
if (entity == null)
{
// ایجاد صفحه جدید (فقط برای seed اولیه — صفحات ثابت هستند)
entity = new SitePageSettings
{
PageKey = request.PageKey,
};
_context.SitePageSettingsEntities.Add(entity);
}
entity.Title = request.Title;
entity.MetaDescription = request.MetaDescription;
entity.HeroTitle = request.HeroTitle;
entity.HeroSubtitle = request.HeroSubtitle;
entity.IsActive = request.IsActive;
entity.SettingsJson = request.SettingsJson;
// آپلود تصویر هیرو
if (request.ImageFileBytes is { Length: > 0 })
{
var result = await _fileManager.UploadImageAsync(
"Images/SitePageSettings",
request.ImageFileBytes,
request.ImageFileMime ?? "image/jpeg",
request.ImageFileName,
cancellationToken);
entity.HeroImagePath = result.Main.Path;
}
await _context.SaveChangesAsync(cancellationToken);
return new SavePageSettingsResult { Id = entity.Id, Success = true };
}
}
@@ -0,0 +1,30 @@
using FluentValidation;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Commands.SavePageSettings;
public class SavePageSettingsCommandValidator : AbstractValidator<SavePageSettingsCommand>
{
private static readonly string[] ValidPageKeys = { "landing", "about", "contact", "licenses" };
public SavePageSettingsCommandValidator()
{
RuleFor(x => x.PageKey)
.NotEmpty().WithMessage("کلید صفحه الزامی است")
.MaximumLength(50)
.Must(key => ValidPageKeys.Contains(key))
.WithMessage("کلید صفحه باید یکی از مقادیر landing, about, contact, licenses باشد");
RuleFor(x => x.Title)
.NotEmpty().WithMessage("عنوان صفحه الزامی است")
.MaximumLength(200).WithMessage("عنوان صفحه حداکثر ۲۰۰ کاراکتر");
RuleFor(x => x.MetaDescription)
.MaximumLength(300).WithMessage("توضیحات متا حداکثر ۳۰۰ کاراکتر");
RuleFor(x => x.HeroTitle)
.MaximumLength(200).WithMessage("عنوان هیرو حداکثر ۲۰۰ کاراکتر");
RuleFor(x => x.HeroSubtitle)
.MaximumLength(500).WithMessage("زیرعنوان هیرو حداکثر ۵۰۰ کاراکتر");
}
}
@@ -0,0 +1,49 @@
using CMSMicroservice.Application.Common.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Queries.GetAllPageSettings;
// ── Query ──
public class GetAllPageSettingsQuery : IRequest<List<PageSettingsSummaryDto>>
{
}
// ── Handler ──
public class GetAllPageSettingsQueryHandler : IRequestHandler<GetAllPageSettingsQuery, List<PageSettingsSummaryDto>>
{
private readonly IApplicationDbContext _context;
public GetAllPageSettingsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<List<PageSettingsSummaryDto>> Handle(GetAllPageSettingsQuery request, CancellationToken cancellationToken)
{
return await _context.SitePageSettingsEntities
.Where(x => !x.IsDeleted)
.OrderBy(x => x.Id)
.Select(x => new PageSettingsSummaryDto
{
Id = x.Id,
PageKey = x.PageKey,
Title = x.Title,
IsActive = x.IsActive,
ImageCount = x.Images.Count(i => !i.IsDeleted),
LastModified = x.LastModified ?? x.Created
})
.ToListAsync(cancellationToken);
}
}
// ── DTO ──
public class PageSettingsSummaryDto
{
public long Id { get; set; }
public string PageKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public bool IsActive { get; set; }
public int ImageCount { get; set; }
public DateTime LastModified { get; set; }
}
@@ -0,0 +1,92 @@
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Entities.Content;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CMSMicroservice.Application.SitePageSettingsCQ.Queries.GetPageSettings;
// ── Query ──
public class GetPageSettingsQuery : IRequest<PageSettingsDto?>
{
public string PageKey { get; set; } = default!;
}
// ── Handler ──
public class GetPageSettingsQueryHandler : IRequestHandler<GetPageSettingsQuery, PageSettingsDto?>
{
private readonly IApplicationDbContext _context;
public GetPageSettingsQueryHandler(IApplicationDbContext context)
{
_context = context;
}
public async Task<PageSettingsDto?> Handle(GetPageSettingsQuery request, CancellationToken cancellationToken)
{
var entity = await _context.SitePageSettingsEntities
.Include(x => x.Images.Where(i => !i.IsDeleted && i.IsActive).OrderBy(i => i.SortOrder))
.FirstOrDefaultAsync(x => x.PageKey == request.PageKey && !x.IsDeleted, cancellationToken);
if (entity == null)
return null;
return MapToDto(entity);
}
public static PageSettingsDto MapToDto(SitePageSettings entity) => new()
{
Id = entity.Id,
PageKey = entity.PageKey,
Title = entity.Title,
MetaDescription = entity.MetaDescription,
HeroTitle = entity.HeroTitle,
HeroSubtitle = entity.HeroSubtitle,
HeroImagePath = entity.HeroImagePath,
IsActive = entity.IsActive,
SettingsJson = entity.SettingsJson,
Images = entity.Images.Select(i => new PageImageDto
{
Id = i.Id,
ImageGroup = i.ImageGroup,
Title = i.Title,
Subtitle = i.Subtitle,
Description = i.Description,
ImagePath = i.ImagePath,
ThumbnailPath = i.ThumbnailPath,
LinkUrl = i.LinkUrl,
IconName = i.IconName,
SortOrder = i.SortOrder,
IsActive = i.IsActive
}).ToList()
};
}
// ── DTOs ──
public class PageSettingsDto
{
public long Id { get; set; }
public string PageKey { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? MetaDescription { get; set; }
public string? HeroTitle { get; set; }
public string? HeroSubtitle { get; set; }
public string? HeroImagePath { get; set; }
public bool IsActive { get; set; }
public string? SettingsJson { get; set; }
public List<PageImageDto> Images { get; set; } = new();
}
public class PageImageDto
{
public long Id { get; set; }
public string ImageGroup { get; set; } = string.Empty;
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? Description { get; set; }
public string ImagePath { get; set; } = string.Empty;
public string? ThumbnailPath { get; set; }
public string? LinkUrl { get; set; }
public string? IconName { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
}
@@ -1,7 +1,7 @@
namespace CMSMicroservice.Application.InventoryItemCQ.Commands.ReduceInventory;
/// <summary>
/// Command برای کم کردن موجودی (فروش)
/// Command برای کم کردن موجودی (فروش، خسارت، مفقودی و ...)
/// </summary>
public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto>
{
@@ -19,4 +19,8 @@ public record ReduceInventoryCommand : IRequest<ReduceInventoryResponseDto>
public long? PerformedByUserId { get; init; }
/// <summary>آیا از موجودی رزرو شده کم شود؟</summary>
public bool FromReserved { get; init; } = true;
/// <summary>نوع حرکت انبار (پیش‌فرض: فروش)</summary>
public Domain.Enums.StockMovementType MovementType { get; init; } = Domain.Enums.StockMovementType.Sale;
/// <summary>توضیحات</summary>
public string? Note { get; init; }
}
@@ -53,11 +53,11 @@ public class ReduceInventoryCommandHandler : IRequestHandler<ReduceInventoryComm
var stockMovement = new StockMovement
{
InventoryItemId = item.Id,
MovementType = StockMovementType.Sale,
Quantity = request.Quantity,
MovementType = request.MovementType,
Quantity = -request.Quantity, // منفی — CHECK constraint: QuantityAfter = QuantityBefore + Quantity
QuantityBefore = previousQuantity,
QuantityAfter = item.Quantity,
Note = "Sale confirmed",
Note = request.Note ?? request.MovementType.ToString(),
ReferenceNumber = request.ReferenceNumber ?? $"SALE-{DateTime.UtcNow:yyyyMMddHHmmss}",
OrderId = request.OrderId,
DiscountOrderId = request.DiscountOrderId,
@@ -108,7 +108,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
wallet.DiscountBalance += manualPayment.Amount;
// لاگ Balance
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -122,7 +122,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
}, cancellationToken);
// لاگ DiscountBalance
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -139,7 +139,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
case ManualPaymentType.DiscountWalletCharge:
wallet.DiscountBalance += manualPayment.Amount;
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -156,7 +156,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
case ManualPaymentType.NetworkWalletCharge:
wallet.NetworkBalance += manualPayment.Amount;
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -183,7 +183,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
wallet.DiscountBalance -= manualPayment.Amount;
}
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -201,7 +201,7 @@ public class ApproveManualPaymentCommandHandler : IRequestHandler<ApproveManualP
// Other یا سایر موارد - فقط Balance
wallet.Balance += manualPayment.Amount;
await _context.UserWalletChangeLogs.AddAsync(new UserWalletChangeLog
await _context.UserWalletHistories.AddAsync(new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -14,10 +14,15 @@ public class CreateManualPaymentCommand : IRequest<long>
public long UserId { get; set; }
/// <summary>
/// مبلغ تراکنش (ریال)
/// مبلغ تراکنش (تومان) — [DEPRECATED] مبلغ از پکیج خوانده می‌شود
/// </summary>
public long Amount { get; set; }
/// <summary>
/// شناسه پکیج — اگر 0 باشد، پکیج پایه استفاده می‌شود
/// </summary>
public long PackageId { get; set; }
/// <summary>
/// نوع تراکنش دستی
/// </summary>
@@ -1,3 +1,4 @@
using CMSMicroservice.Application.ClubMembershipCQ.Commands.ActivateClubMembership;
using CMSMicroservice.Application.Common.Exceptions;
using CMSMicroservice.Application.Common.Interfaces;
using CMSMicroservice.Domain.Common;
@@ -14,15 +15,18 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
{
private readonly IApplicationDbContext _context;
private readonly ICurrentUserService _currentUser;
private readonly ISender _sender;
private readonly ILogger<CreateManualPaymentCommandHandler> _logger;
public CreateManualPaymentCommandHandler(
IApplicationDbContext context,
ICurrentUserService currentUser,
ISender sender,
ILogger<CreateManualPaymentCommandHandler> logger)
{
_context = context;
_currentUser = currentUser;
_sender = sender;
_logger = logger;
}
@@ -70,9 +74,23 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
throw new NotFoundException($"کیف پول کاربر {request.UserId} یافت نشد");
}
// 4. محاسبه مبالغ
var balanceAmount = SystemConstants.BasePackageAmount; // 56M
var discountBalanceAmount = SystemConstants.BasePackageAmount * 2; // 112M
// 4. بارگذاری پکیج انتخاب‌شده (یا پکیج پایه اگر PackageId=0)
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)
?? throw new NotFoundException("پکیج پایه یافت نشد");
}
var balanceAmount = package.Price;
var discountBalanceAmount = (long)(package.Price * package.DiscountMultiplier);
// 5. ثبت تراکنش
@@ -112,11 +130,11 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
var oldBalance = wallet.Balance;
var oldDiscountBalance = wallet.DiscountBalance;
wallet.Balance += balanceAmount; // +56M
wallet.DiscountBalance += discountBalanceAmount; // +112M
wallet.Balance += balanceAmount;
wallet.DiscountBalance += discountBalanceAmount;
// 8. ثبت لاگ کیف پول
var walletLog = new UserWalletChangeLog
var walletLog = new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = 0,
@@ -126,17 +144,51 @@ public class CreateManualPaymentCommandHandler : IRequestHandler<CreateManualPay
CurrentDiscountBalance =0,
ChangeDiscountValue = discountBalanceAmount,
IsIncrease = true,
RefrenceId = transaction.Id
RefrenceId = transaction.Id,
PackageId = package.Id
};
await _context.UserWalletChangeLogs.AddAsync(walletLog, cancellationToken);
await _context.UserWalletHistories.AddAsync(walletLog, cancellationToken);
// 9. تنظیم روش خرید پکیج
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
// 9. تنظیم روش خرید پکیج + ثبت ledger خرید دستی
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. ذخیره همه تغییرات
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(
"Manual membership payment created successfully. " +
"ManualPaymentId: {Id}, UserId: {UserId}, TransactionId: {TransactionId}, " +
@@ -1,3 +1,4 @@
using CMSMicroservice.Domain.Common;
using FluentValidation;
namespace CMSMicroservice.Application.ManualPaymentCQ.Commands.CreateManualPayment;
@@ -10,11 +11,10 @@ public class CreateManualPaymentCommandValidator : AbstractValidator<CreateManua
.GreaterThan(0)
.WithMessage("شناسه کاربر باید بزرگتر از صفر باشد");
RuleFor(x => x.Amount)
.GreaterThan(0)
.WithMessage("مبلغ باید بزرگتر از صفر باشد")
.LessThanOrEqualTo(1_000_000_000)
.WithMessage("مبلغ نمی‌تواند بیشتر از 1 میلیارد ریال باشد");
// مبلغ از پکیج خوانده می‌شود — فیلد Amount دیگر اعتبارسنجی نمی‌شود
RuleFor(x => x.PackageId)
.GreaterThanOrEqualTo(0)
.WithMessage("شناسه پکیج نامعتبر است");
RuleFor(x => x.Type)
.IsInEnum()
@@ -8,7 +8,7 @@ public record ProcessManualMembershipPaymentCommand : IRequest<ProcessManualMemb
public long UserId { get; init; }
/// <summary>
/// مبلغ پرداختی (ریال)
/// مبلغ پرداختی (تومان)
/// </summary>
public long Amount { get; init; }
@@ -104,7 +104,7 @@ public class ProcessManualMembershipPaymentCommandHandler : IRequestHandler<Proc
wallet.DiscountBalance += request.Amount;
// 8. ثبت لاگ Balance
var balanceLog = new UserWalletChangeLog
var balanceLog = new UserWalletHistory
{
WalletId = wallet.Id,
CurrentBalance = wallet.Balance,
@@ -116,9 +116,9 @@ public class ProcessManualMembershipPaymentCommandHandler : IRequestHandler<Proc
IsIncrease = true,
RefrenceId = transaction.Id
};
await _context.UserWalletChangeLogs.AddAsync(balanceLog, cancellationToken);
await _context.UserWalletHistories.AddAsync(balanceLog, cancellationToken);
user.PackagePurchaseMethod = PackagePurchaseMethod.DirectPurchase;
user.PackagePurchaseMethod = PackagePurchaseMethod.Manual;
// 10. به‌روزرسانی ManualPayment با TransactionId
manualPayment.TransactionId = transaction.Id;
await _context.SaveChangesAsync(cancellationToken);
@@ -69,6 +69,14 @@ public class GetAllManualPaymentsQueryHandler
// Pagination
var skip = (request.PageNumber - 1) * request.PageSize;
// دریافت PackageName پیش‌فرض از آخرین خرید پکیج کاربر (fallback برای داده‌های قدیمی)
var userPackageNames = await _context.UserPackagePurchases
.Include(p => p.Package)
.GroupBy(p => p.UserId)
.Select(g => new { UserId = g.Key, PackageName = g.OrderByDescending(x => x.PurchasedAt).First().Package.Title })
.ToDictionaryAsync(x => x.UserId, x => x.PackageName, cancellationToken);
var data = await query
.Skip(skip)
.Take(request.PageSize)
@@ -96,6 +104,46 @@ public class GetAllManualPaymentsQueryHandler
})
.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
foreach (var item in data)
{
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;
}
}
var metaData = new MetaData
{
TotalCount = totalCount,
@@ -30,4 +30,5 @@ public class ManualPaymentDto
public string? RejectionReason { get; set; }
public long? TransactionId { get; set; }
public DateTime Created { get; set; }
public string? PackageName { get; set; }
}

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