Compare commits

..

147 Commits

Author SHA1 Message Date
Khwezi Mngoma 516062ed5d Refactored order item retrieval error message
continuous-integration/drone/pr Build is passing
2026-06-15 12:08:19 +02:00
Khwezi Mngoma 16832ec214 Added GetOrderItems to OrderService 2026-06-15 12:06:33 +02:00
Khwezi Mngoma cf439c5006 Ensure uninherited types are sealed
continuous-integration/drone/pr Build is passing
Resolved mediator source geenrator conflict with tests
2026-06-15 10:27:44 +02:00
Khwezi Mngoma bf36bb6bbc Hardened certificate loading
continuous-integration/drone/pr Build is passing
2026-06-14 23:34:25 +02:00
Khwezi Mngoma 00d43c8f10 Refactored AddLiteCharmsWebSecurity to force a session recycling of keys on other pods
continuous-integration/drone/pr Build is passing
2026-06-14 23:10:09 +02:00
Khwezi Mngoma 40a5f94941 Refactored CheckSameSite
continuous-integration/drone/pr Build is passing
2026-06-14 22:50:31 +02:00
Khwezi Mngoma 9cb4b8264d Refactored Api registration methods
continuous-integration/drone/pr Build is passing
2026-06-14 13:11:40 +02:00
Khwezi Mngoma ddd823afab Configured AddLiteCharmsWebSecurity() to setup ConfigureCookieOidcSameSiteSupport() 2026-06-14 13:09:57 +02:00
Khwezi Mngoma 6418d27f5a Added cookie policies on AddLiteCharmsWebSecurity
continuous-integration/drone/pr Build is passing
2026-06-14 12:50:13 +02:00
Khwezi Mngoma 9b15e296df Added certificate protected data protection keys
continuous-integration/drone/pr Build is passing
2026-06-14 11:26:20 +02:00
Khwezi Mngoma f4a615277f Refactored AddDataProtectionDatabase
continuous-integration/drone/pr Build is passing
2026-06-14 10:11:25 +02:00
Khwezi Mngoma 4e9e428ab5 Added data protection database based support
continuous-integration/drone/pr Build is passing
2026-06-14 09:57:24 +02:00
Khwezi Mngoma 9099610185 Refactored AddLiteCharmsWebSecurity to be OS aware when it handles data protection keys
continuous-integration/drone/pr Build is passing
2026-06-13 23:41:02 +02:00
Khwezi Mngoma dfaa62ea75 Added data protection keys to web iodc middleware regirtration method
continuous-integration/drone/pr Build is passing
2026-06-13 23:34:07 +02:00
Khwezi Mngoma 0ec7ef4861 Fixed cookie and header collision issue on signout
continuous-integration/drone/pr Build is passing
2026-06-13 23:06:53 +02:00
Khwezi Mngoma 088e64f28f Fixed the redirect URI on logout so its passed by the caller
continuous-integration/drone/pr Build is passing
2026-06-13 22:51:07 +02:00
Khwezi Mngoma 3803ae2999 Merged incoming changes 2026-06-13 21:39:43 +02:00
Khwezi Mngoma 398a8d3827 Refactored service bus lifetiemes to singleton 2026-06-13 21:34:59 +02:00
khwezi b09af460f1 Merge pull request 'Refactored usaged of merchant payment id usage' (#117) from payments into master
Reviewed-on: #117
2026-06-13 21:21:21 +02:00
Khwezi Mngoma 7c5b5f1728 Refactored usaged of merchant payment id usage
continuous-integration/drone/pr Build is passing
2026-06-13 21:20:30 +02:00
khwezi 50db61b3ee Merge pull request 'Refactored idempotency check to observe completed status' (#116) from payments into master
Reviewed-on: #116
2026-06-13 18:04:17 +02:00
Khwezi Mngoma 94b0084d75 Refactored idempotency check to observe completed status
continuous-integration/drone/pr Build is passing
2026-06-13 18:03:40 +02:00
khwezi f5ad8e2d50 Merge pull request 'Ensured the merchant payment id makes it to the ledger' (#115) from payments into master
Reviewed-on: #115
2026-06-13 17:01:36 +02:00
Khwezi Mngoma 8e2942487d Ensured the merchant payment id makes it to the ledger
continuous-integration/drone/pr Build is passing
2026-06-13 17:00:08 +02:00
khwezi fa79a58004 Merge pull request 'Fixed package mismatches' (#114) from payments into master
Reviewed-on: #114
2026-06-13 16:33:05 +02:00
Khwezi Mngoma 9997d4f0ed Fixed package mismatches
continuous-integration/drone/pr Build is passing
2026-06-13 16:32:39 +02:00
khwezi 33edae9eff Merge pull request 'Simplified PayfastPaymentConfirmationReceivedEventHandler' (#113) from payments into master
Reviewed-on: #113
2026-06-13 16:27:28 +02:00
Khwezi Mngoma c1e52ea908 Simplified PayfastPaymentConfirmationReceivedEventHandler
continuous-integration/drone/pr Build is failing
2026-06-13 16:26:47 +02:00
khwezi 0d5702f0fe Merge pull request 'Using IFormCollection for VerifyIncomingSignatureFromForm' (#112) from payments into master
Reviewed-on: #112
2026-06-13 16:05:57 +02:00
Khwezi Mngoma e4c3779092 Using IFormCollection for VerifyIncomingSignatureFromForm
continuous-integration/drone/pr Build is passing
2026-06-13 16:03:31 +02:00
khwezi da5f233c3b Merge pull request 'refactored incoming signature validator to use form fields instead of httprequest' (#111) from payments into master
Reviewed-on: #111
2026-06-13 15:58:58 +02:00
Khwezi Mngoma 02d89eec4f refactored incoming signature validator to use form fields instead of httprequest
continuous-integration/drone/pr Build is passing
2026-06-13 15:58:30 +02:00
khwezi 95dc2e2da2 Merge pull request 'payments' (#110) from payments into master
Reviewed-on: #110
2026-06-13 15:50:20 +02:00
Khwezi Mngoma 59fc0432b4 ensure alphabetical sorting
continuous-integration/drone/pr Build is passing
2026-06-13 15:49:45 +02:00
Khwezi Mngoma 99c0508f6f Implemented separate signature validator 2026-06-13 15:45:59 +02:00
Khwezi Mngoma b984dab2be Updated valid payfast addresses 2026-06-13 12:08:23 +02:00
khwezi 157f097dfb Merge pull request 'Catering for service registration of non-UI apps' (#109) from payments into master
Reviewed-on: #109
2026-06-13 10:46:11 +02:00
Khwezi Mngoma 630e74814b Catering for service registration of non-UI apps
continuous-integration/drone/pr Build is passing
2026-06-13 10:45:31 +02:00
khwezi 6248d03ead Merge pull request 'Removed automatic service registration for the CartService' (#108) from payments into master
Reviewed-on: #108
2026-06-13 10:22:52 +02:00
Khwezi Mngoma 9b474a398b Removed automatic service registration for the CartService
continuous-integration/drone/pr Build is passing
2026-06-13 10:22:24 +02:00
khwezi 3deae15f5a Merge pull request 'Removed automatic LocalStorageService registration' (#107) from payments into master
Reviewed-on: #107
2026-06-13 10:19:13 +02:00
Khwezi Mngoma 8e1df7938b Removed automatic LocalStorageService registration
continuous-integration/drone/pr Build is passing
2026-06-13 10:18:42 +02:00
khwezi d9f2d32c76 Merge pull request 'Refactored registration of Features service from Scoped to Transient' (#106) from payments into master
Reviewed-on: #106
2026-06-13 10:07:27 +02:00
Khwezi Mngoma 9296f0331e Refactored registration of Features service from Scoped to Transient
continuous-integration/drone/pr Build is passing
2026-06-13 10:06:54 +02:00
khwezi 1ace61baa5 Merge pull request 'Honoring the mandatory field sequence' (#105) from payments into master
Reviewed-on: #105
2026-06-12 23:30:43 +02:00
Khwezi Mngoma e3e49b8db2 Honoring the mandatory field sequence
continuous-integration/drone/pr Build is passing
2026-06-12 23:30:13 +02:00
khwezi 2ed15b548f Merge pull request 'Refactored PayfastService.GenerateSignature()' (#104) from payments into master
Reviewed-on: #104
2026-06-12 23:27:21 +02:00
Khwezi Mngoma 7d2bc7f1f2 Refactored PayfastService.GenerateSignature()
continuous-integration/drone/pr Build is passing
2026-06-12 23:26:54 +02:00
khwezi ef2428f8e3 Merge pull request 'Refactored GenerateSignature' (#103) from payments into master
Reviewed-on: #103
2026-06-12 23:20:08 +02:00
Khwezi Mngoma 5edff5e272 Refactored GenerateSignature
continuous-integration/drone/pr Build is passing
2026-06-12 23:19:40 +02:00
khwezi b424b24c2e Merge pull request 'Changed optional fields on Customer entity' (#102) from payments into master
Reviewed-on: #102
2026-06-12 23:02:02 +02:00
Khwezi Mngoma 310c1237b1 Changed optional fields on Customer entity
continuous-integration/drone/pr Build is passing
2026-06-12 23:00:57 +02:00
khwezi cadc5888cc Merge pull request 'Added new service methods' (#101) from payments into master
Reviewed-on: #101
2026-06-12 22:09:17 +02:00
Khwezi Mngoma 618e57074a Added new service methods
continuous-integration/drone/pr Build is passing
2026-06-12 22:08:54 +02:00
khwezi 92abf6c5be Merge pull request 'Added GetBookByProductId()' (#100) from payments into master
Reviewed-on: #100
2026-06-12 21:58:03 +02:00
Khwezi Mngoma b60b8236af Added GetBookByProductId()
continuous-integration/drone/pr Build is passing
2026-06-12 21:57:34 +02:00
khwezi 37e0d3b93f Merge pull request 'Completed token service' (#99) from payments into master
Reviewed-on: #99
2026-06-12 20:58:57 +02:00
Khwezi Mngoma 4d2b37ace7 Completed token service
continuous-integration/drone/pr Build is passing
2026-06-12 20:48:12 +02:00
khwezi b42c0fcc4f Merge pull request 'Fixd double package reference to Mediator.SourceGenerator' (#98) from payments into master
Reviewed-on: #98
2026-06-12 16:16:05 +02:00
Khwezi Mngoma 3daf192ce9 Fixd double package reference to Mediator.SourceGenerator
continuous-integration/drone/pr Build is passing
2026-06-12 16:15:44 +02:00
khwezi 9418c77f0e Merge pull request 'Implemented LiteCharms Security TokenService' (#97) from payments into master
Reviewed-on: #97
2026-06-12 16:12:10 +02:00
Khwezi Mngoma a98adea8f3 Implemented LiteCharms Security TokenService
continuous-integration/drone/pr Build is passing
2026-06-12 16:09:51 +02:00
khwezi 2bb1c8c3bc Merge pull request 'Added CartService' (#96) from payments into master
Reviewed-on: #96
2026-06-12 08:44:58 +02:00
Khwezi Mngoma f5f1035598 Added CartService
continuous-integration/drone/pr Build is passing
2026-06-12 08:43:58 +02:00
khwezi 388a5f4c87 Merge pull request 'Removed shopping cart objects' (#95) from payments into master
Reviewed-on: #95
2026-06-09 22:33:20 +02:00
Khwezi Mngoma 40f4656036 Removed shopping cart objects
continuous-integration/drone/pr Build is passing
2026-06-09 22:32:54 +02:00
khwezi c31dd308a4 Merge pull request 'payments' (#94) from payments into master
Reviewed-on: #94
2026-06-09 21:53:52 +02:00
Khwezi Mngoma 11dfd18a44 Ensured the assembly scanner picks up shared and core components
continuous-integration/drone/pr Build is passing
2026-06-09 21:52:31 +02:00
Khwezi Mngoma e7f02eca9b Refactored Shop services assembly scanner to be more generic 2026-06-09 21:43:47 +02:00
khwezi 787507bed9 Merge pull request 'Added CartService and LocalStorageService (browser)' (#93) from payments into master
Reviewed-on: #93
2026-06-09 09:10:32 +02:00
Khwezi Mngoma 59af9a5406 Added CartService and LocalStorageService (browser)
continuous-integration/drone/pr Build is passing
2026-06-09 09:08:46 +02:00
khwezi 5140da2c6c Merge pull request 'Passing token hint during signout' (#92) from payments into master
Reviewed-on: #92
2026-06-07 14:09:31 +02:00
Khwezi Mngoma 02ff14ccc8 Passing token hint during signout
continuous-integration/drone/pr Build is passing
2026-06-07 14:09:02 +02:00
khwezi 0ad410c64e Merge pull request 'payments' (#91) from payments into master
Reviewed-on: #91
2026-06-07 13:57:15 +02:00
Khwezi Mngoma e193aa7c1c Adopted standard logout flow
continuous-integration/drone/pr Build is running
2026-06-07 13:56:49 +02:00
Khwezi Mngoma 840d4568e2 Refactored logout process 2026-06-07 13:55:20 +02:00
khwezi 6e580ecdf6 Merge pull request 'Added token hint to logout event' (#90) from payments into master
Reviewed-on: #90
2026-06-07 13:12:05 +02:00
Khwezi Mngoma 60095057b7 Added token hint to logout event
continuous-integration/drone/pr Build is passing
2026-06-07 13:11:33 +02:00
khwezi 4c194c1141 Merge pull request 'Added AddSecurityEndpoints login endpoint' (#89) from payments into master
Reviewed-on: #89
2026-06-07 12:00:50 +02:00
Khwezi Mngoma b41136e2c7 Added AddSecurityEndpoints login endpoint
continuous-integration/drone/pr Build is passing
2026-06-07 12:00:07 +02:00
khwezi 41eb4daeb4 Merge pull request 'Refactored AddSecurityEndpoints' (#88) from payments into master
Reviewed-on: #88
2026-06-07 11:09:02 +02:00
Khwezi Mngoma c423f04b42 Refactored AddSecurityEndpoints
continuous-integration/drone/pr Build is passing
2026-06-07 11:08:05 +02:00
khwezi 7fe5f7aef3 Merge pull request 'Refactored client auth method' (#87) from payments into master
Reviewed-on: #87
2026-06-07 10:33:33 +02:00
Khwezi Mngoma a567fc7cd7 Refactored client auth method
continuous-integration/drone/pr Build is passing
2026-06-07 10:30:56 +02:00
khwezi 31254932ae Merge pull request 'Enabled api audience verification' (#86) from payments into master
Reviewed-on: #86
2026-06-06 23:54:13 +02:00
Khwezi Mngoma c53434a578 Enabled api audience verification
continuous-integration/drone/pr Build is passing
2026-06-06 23:53:19 +02:00
khwezi 5a0aae8182 Merge pull request 'Disabled audience verification on jwt tokens' (#85) from payments into master
Reviewed-on: #85
2026-06-06 21:50:48 +02:00
Khwezi Mngoma 3f3e0dbe88 Disabled audience verification on jwt tokens
continuous-integration/drone/pr Build is passing
2026-06-06 21:49:32 +02:00
khwezi 8d8ebffabf Merge pull request 'Removed required scope policy, no longer needed, audience covers the intent' (#84) from payments into master
Reviewed-on: #84
2026-06-06 16:44:52 +02:00
Khwezi Mngoma dc4addb43a Removed required scope policy, no longer needed, audience covers the intent
continuous-integration/drone/pr Build is passing
2026-06-06 16:44:22 +02:00
khwezi e2d29261da Merge pull request 'Updated API to use LiteCharms Security' (#83) from payments into master
Reviewed-on: #83
2026-06-06 16:27:16 +02:00
Khwezi Mngoma 5d5b59d610 Updated API to use LiteCharms Security
continuous-integration/drone/pr Build is passing
2026-06-06 16:26:27 +02:00
khwezi f001b02633 Merge pull request 'Refactored to deal with cookie hell' (#82) from payments into master
Reviewed-on: #82
2026-06-05 09:20:17 +02:00
Khwezi Mngoma 90a11dc65e Refactored to deal with cookie hell
continuous-integration/drone/pr Build is passing
2026-06-05 09:19:32 +02:00
khwezi de955a96a8 Merge pull request 'Removed login proto handling' (#81) from payments into master
Reviewed-on: #81
2026-06-05 08:56:17 +02:00
Khwezi Mngoma cdf5cfb5cd Removed login proto handling
continuous-integration/drone/pr Build is passing
2026-06-05 08:55:31 +02:00
khwezi c4d3bb4cdf Merge pull request 'Simplified login process' (#80) from payments into master
Reviewed-on: #80
2026-06-05 08:18:12 +02:00
Khwezi Mngoma 65f102f18a Simplified login process
continuous-integration/drone/pr Build is passing
2026-06-05 08:17:32 +02:00
khwezi cdc80db214 Merge pull request 'Refactored logout endpoint' (#79) from payments into master
Reviewed-on: #79
2026-06-05 08:15:50 +02:00
Khwezi Mngoma 4576b5aa2b Refactored logout endpoint
continuous-integration/drone/pr Build is passing
2026-06-05 08:15:13 +02:00
khwezi 3847927ace Merge pull request 'Added port stripping' (#78) from payments into master
Reviewed-on: #78
2026-06-05 07:37:16 +02:00
Khwezi Mngoma d38d1dd059 Added port stripping
continuous-integration/drone/pr Build is passing
2026-06-05 07:36:41 +02:00
khwezi c27aba1954 Merge pull request 'Forcing login https proto on redirect' (#77) from payments into master
Reviewed-on: #77
2026-06-05 06:40:33 +02:00
Khwezi Mngoma e646d16053 Forcing login https proto on redirect
continuous-integration/drone/pr Build is passing
2026-06-05 06:39:47 +02:00
khwezi 1c946dab26 Merge pull request 'Refactored security components' (#76) from payments into master
Reviewed-on: #76
2026-06-05 05:44:47 +02:00
Khwezi Mngoma 20c3ad9569 Refactored security components
continuous-integration/drone/pr Build is passing
2026-06-05 05:43:56 +02:00
khwezi 9977cf27b9 Merge pull request 'Added a redirect packet attachment to UI signout process' (#75) from payments into master
Reviewed-on: #75
2026-06-04 16:03:06 +02:00
Khwezi Mngoma cf7eed0603 Added a redirect packet attachment to UI signout process
continuous-integration/drone/pr Build is passing
2026-06-04 16:02:29 +02:00
khwezi 8e9ac1e1ad Merge pull request 'Added signout functionality for user authentik link' (#74) from payments into master
Reviewed-on: #74
2026-06-04 14:40:00 +02:00
Khwezi Mngoma fa79bd8021 Added signout functionality for user authentik link
continuous-integration/drone/pr Build is passing
2026-06-04 14:39:14 +02:00
khwezi 16dae7c9fb Merge pull request 'Added UI security' (#73) from payments into master
Reviewed-on: #73
2026-06-04 14:09:08 +02:00
Khwezi Mngoma 5666ffd474 Added UI security
continuous-integration/drone/pr Build is passing
2026-06-04 14:08:27 +02:00
khwezi f8153e86b4 Merge pull request 'Applied required scope policy' (#72) from payments into master
Reviewed-on: #72
2026-06-04 08:58:03 +02:00
Khwezi Mngoma eef1096ec5 Applied required scope policy
continuous-integration/drone/pr Build is passing
2026-06-04 08:57:16 +02:00
khwezi 84d33d3607 Merge pull request 'Refactored authentication' (#71) from payments into master
Reviewed-on: #71
2026-06-04 08:48:15 +02:00
Khwezi Mngoma 8f97d7cf38 Refactored authentication
continuous-integration/drone/pr Build is passing
2026-06-04 08:47:18 +02:00
khwezi f51cc03327 Merge pull request 'Disabled caching' (#70) from payments into master
Reviewed-on: #70
2026-06-03 17:49:04 +02:00
Khwezi Mngoma 652ca82a57 Disabled caching
continuous-integration/drone/pr Build is passing
2026-06-03 17:48:38 +02:00
khwezi aff6fcabf4 Merge pull request 'payments' (#69) from payments into master
Reviewed-on: #69
2026-06-03 17:38:45 +02:00
Khwezi Mngoma a50830ffaa Refactored auth
continuous-integration/drone/pr Build is passing
2026-06-03 17:37:56 +02:00
Khwezi Mngoma ee6f8a283e Refactored oauth registration 2026-06-03 17:37:33 +02:00
khwezi 8140b5fe65 Merge pull request 'Added authentik configuration and service registration' (#68) from payments into master
Reviewed-on: #68
2026-06-03 12:53:55 +02:00
Khwezi Mngoma fda97db5fa Added authentik configuration and service registration
continuous-integration/drone/pr Build is passing
2026-06-03 12:52:59 +02:00
khwezi 9285cedfa9 Merge pull request 'Refactored token message' (#67) from payments into master
Reviewed-on: #67
2026-06-03 12:16:31 +02:00
Khwezi Mngoma 29574f4df0 Refactored token message
continuous-integration/drone/pr Build is passing
2026-06-03 12:15:31 +02:00
khwezi 343874551a Merge pull request 'Added 0.0.0.0 health checks url transformation' (#66) from payments into master
Reviewed-on: #66
2026-06-03 11:24:02 +02:00
Khwezi Mngoma b4a48c9cbf Added 0.0.0.0 health checks url transformation
continuous-integration/drone/pr Build is passing
2026-06-03 11:23:13 +02:00
khwezi 0eac9d533f Merge pull request 'payments' (#65) from payments into master
Reviewed-on: #65
2026-06-03 11:12:10 +02:00
Khwezi Mngoma 961f03c1c7 Added guardrails around the cluster as well as software level
continuous-integration/drone/pr Build is passing
2026-06-03 11:11:22 +02:00
Khwezi Mngoma a0cf847e51 Added job interruption handling 2026-06-03 10:40:29 +02:00
khwezi 24ba609e0c Merge pull request 'Excluded http environment from checkin' (#64) from payments into master
Reviewed-on: #64
2026-06-03 00:51:30 +02:00
Khwezi Mngoma 4bac14881d Excluded http environment from checkin
continuous-integration/drone/pr Build is passing
2026-06-03 00:50:20 +02:00
khwezi 29f6d66c44 Merge pull request 'Fixed tests' (#63) from payments into master
Reviewed-on: #63
2026-06-03 00:41:26 +02:00
Khwezi Mngoma fd6057d691 Fixed tests
continuous-integration/drone/pr Build is passing
2026-06-03 00:41:02 +02:00
khwezi bcfc9ef962 Merge pull request 'Added loopback address whitelisting override' (#62) from payments into master
Reviewed-on: #62
2026-06-03 00:38:29 +02:00
Khwezi Mngoma 7961d934ba Added loopback address whitelisting override
continuous-integration/drone/pr Build is failing
2026-06-03 00:37:59 +02:00
khwezi b4e967acc9 Merge pull request 'payments' (#61) from payments into master
Reviewed-on: #61
2026-06-03 00:23:22 +02:00
Khwezi Mngoma 0a95df4c39 Added midrand shop .http test folder
continuous-integration/drone/pr Build is passing
2026-06-03 00:22:44 +02:00
Khwezi Mngoma ad9fa0ab91 Added http test folder to features test project 2026-06-03 00:21:57 +02:00
Khwezi Mngoma 4df903e456 Added shared api feature 2026-06-03 00:20:46 +02:00
khwezi 205bbb9f3f Merge pull request 'payments' (#60) from payments into master
Reviewed-on: #60
2026-06-02 23:48:29 +02:00
Khwezi Mngoma 763d24f11f Updated nuget packages
continuous-integration/drone/pr Build is passing
2026-06-02 23:47:10 +02:00
Khwezi Mngoma 0ed04211bf Added payment gateway ledger service to payments feature 2026-06-02 23:44:45 +02:00
khwezi 73ef4b04a9 Merge pull request 'Used scope to inject services' (#59) from payments into master
Reviewed-on: #59
2026-06-02 00:03:50 +02:00
Khwezi Mngoma 5ab2d29aac Used scope to inject services
continuous-integration/drone/pr Build is passing
2026-06-02 00:03:01 +02:00
khwezi 780415b6d4 Merge pull request 'Fixed event service scope issue' (#58) from payments into master
Reviewed-on: #58
2026-06-01 23:33:11 +02:00
Khwezi Mngoma 139ca1f866 Fixed event service scope issue
continuous-integration/drone/pr Build is passing
2026-06-01 23:32:35 +02:00
108 changed files with 6517 additions and 504 deletions
+4 -1
View File
@@ -360,4 +360,7 @@ MigrationBackup/
.ionide/ .ionide/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
/LiteCharms.Features.Tests/http/http-client.env.json
/LiteCharms.Features.Tests/http/midrandshop-api/http-client.env.json
/LiteCharms.Features.Tests/http/authentik/http-client.env.json
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SignAssembly>True</SignAssembly>
<AssemblyOriginatorKeyFile>..\LiteCharms.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>
<!-- Nuget Package Details -->
<PropertyGroup>
<PackageId>LiteCharms.Abstractions</PackageId>
<Version>1.0.20</Version>
<Authors>Khwezi Mngoma</Authors>
<Company>Lite Charms (PTY) Ltd</Company>
<Description>Shared abstractions for Lite Charms applications.</Description>
<PackageProjectUrl>https://gitea.khongisa.co.za/litecharms/components</PackageProjectUrl>
<RepositoryUrl>https://gitea.khongisa.co.za/litecharms/components.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageTags>utility;dotnet</PackageTags>
<PackageIcon>icon.png</PackageIcon>
</PropertyGroup>
<ItemGroup>
<None Include="..\LICENSE" Pack="true" PackagePath="\" />
<None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="Mediator.Abstractions" Version="3.0.2" />
<Using Include="Mediator" />
<Using Include="FluentResults" />
<Using Include="System.Threading.Channels" />
</ItemGroup>
</Project>
@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<SignAssembly>True</SignAssembly>
<AssemblyOriginatorKeyFile>..\LiteCharms.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>
<!-- Nuget Package Details -->
<PropertyGroup>
<PackageId>LiteCharms.Entities</PackageId>
<Version>1.0.20</Version>
<Authors>Khwezi Mngoma</Authors>
<Company>Lite Charms (PTY) Ltd</Company>
<Description>Shared entities for Lite Charms applications.</Description>
<PackageProjectUrl>https://gitea.khongisa.co.za/litecharms/components</PackageProjectUrl>
<RepositoryUrl>https://gitea.khongisa.co.za/litecharms/components.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageTags>utility;dotnet</PackageTags>
<PackageIcon>icon.png</PackageIcon>
</PropertyGroup>
<ItemGroup>
<None Include="..\LICENSE" Pack="true" PackagePath="\"/>
<None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<!-- Database -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<!-- Global Usings -->
<Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LiteCharms.Models\LiteCharms.Models.csproj" />
</ItemGroup>
</Project>
@@ -11,12 +11,12 @@
<!-- Quartz Scheduler--> <!-- Quartz Scheduler-->
<ItemGroup> <ItemGroup>
<PackageReference Include="Bogus" Version="35.6.5" /> <PackageReference Include="Bogus" Version="35.6.5" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.98"> <PackageReference Include="Meziantou.Analyzer" Version="3.0.104">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.FeatureManagement.AspNetCore" Version="4.5.0" /> <PackageReference Include="Microsoft.FeatureManagement.AspNetCore" Version="4.5.0" />
<PackageReference Include="OpenTelemetry" Version="1.15.3" /> <PackageReference Include="OpenTelemetry" Version="1.16.0" />
<PackageReference Include="Quartz" Version="3.18.1" /> <PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" /> <PackageReference Include="Quartz.Plugins" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" /> <PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
@@ -30,11 +30,11 @@
<!-- Configuration --> <!-- Configuration -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" /> <Using Include="Microsoft.Extensions.Configuration" />
@@ -47,9 +47,9 @@
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" /> <Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
@@ -58,12 +58,12 @@
<!-- Open Telemetry --> <!-- Open Telemetry -->
<ItemGroup> <ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.16.0" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" /> <Using Include="OpenTelemetry.Resources" />
@@ -75,13 +75,13 @@
<!-- Database --> <!-- Database -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -116,8 +116,8 @@
<!-- Amazon S3 SDK --> <!-- Amazon S3 SDK -->
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.1" /> <PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.7" />
<PackageReference Include="AWSSDK.S3" Version="4.0.23.4" /> <PackageReference Include="AWSSDK.S3" Version="4.0.24.4" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" /> <ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
<!-- global Usings --> <!-- global Usings -->
@@ -14,7 +14,7 @@ builder.Services.AddScopedFeatureManagement();
builder.Services builder.Services
.AddLogging() .AddLogging()
.AddShopServices() .AddShopServices()
.AddHostedService<ProductsSeederService>() .AddHostedService<ProductsSeederService>()
.AddHostedService<CategorySeederService>() .AddHostedService<CategorySeederService>()
.AddHostedService<CustomerSeederService>() .AddHostedService<CustomerSeederService>()
@@ -1,6 +1,6 @@
{ {
"FeatureManagement": { "FeatureManagement": {
"CategorySeederService": true, "CategorySeederService": false,
"CustomerSeederService": false, "CustomerSeederService": false,
"ProductsSeederService": false "ProductsSeederService": false
}, },
@@ -1,7 +1,7 @@
using LiteCharms.Features.MidrandBooks.Authors; using LiteCharms.Features.MidrandBooks.Authors;
using LiteCharms.Features.MidrandBooks.Authors.Models; using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Tests.Common;
using LiteCharms.Features.Models; using LiteCharms.Features.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
@@ -1,5 +1,5 @@
using LiteCharms.Features.MidrandBooks.AuthorBooks; using LiteCharms.Features.MidrandBooks.AuthorBooks;
using LiteCharms.Features.MidrandBooks.Tests.Common; using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
@@ -1,5 +1,5 @@
using LiteCharms.Features.MidrandBooks.Categories; using LiteCharms.Features.MidrandBooks.Categories;
using LiteCharms.Features.MidrandBooks.Tests.Common; using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
@@ -1,6 +1,6 @@
using LiteCharms.Features.MidrandBooks.Customers; using LiteCharms.Features.MidrandBooks.Customers;
using LiteCharms.Features.MidrandBooks.Customers.Models; using LiteCharms.Features.MidrandBooks.Customers.Models;
using LiteCharms.Features.MidrandBooks.Tests.Common; using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
@@ -12,12 +12,8 @@
<PackageReference Include="coverlet.collector" Version="10.0.1"> <PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2"> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -35,19 +31,15 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\LiteCharms.Features.MidrandBooks\LiteCharms.Features.MidrandBooks.csproj" /> <ProjectReference Include="..\LiteCharms.Features.MidrandBooks\LiteCharms.Features.MidrandBooks.csproj" />
<ProjectReference Include="..\LiteCharms.Features.Tests.Common\LiteCharms.Features.Tests.Common.csproj" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" /> <ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="System.Net" />
<Using Include="System.Text.Json" /> <Using Include="System.Text.Json" />
<Using Include="System.Diagnostics" /> <Using Include="System.Diagnostics" />
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>
@@ -1,7 +1,7 @@
using LiteCharms.Features.MidrandBooks.Orders; using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Orders.Models; using LiteCharms.Features.MidrandBooks.Orders.Models;
using LiteCharms.Features.MidrandBooks.Tests.Common;
using LiteCharms.Features.Models; using LiteCharms.Features.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
@@ -1,5 +1,5 @@
using LiteCharms.Features.MidrandBooks.Pages; using LiteCharms.Features.MidrandBooks.Pages;
using LiteCharms.Features.MidrandBooks.Tests.Common; using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
@@ -0,0 +1,113 @@
using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests;
public sealed class PayfastServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly PayfastService payfastService = fixture.Services.GetRequiredService<PayfastService>();
[IntegrationFact]
public async Task WriteLedgerEntryAsync_ShouldReturn_ResultWithGatewayLedgerId()
{
var request = new CreateGatewayLedgerEntry
{
OrderId = 1,
PaymentId = 1,
MerchantPaymentId = "M_REF_TEST_99",
PayfastPaymentId = "PF_SYS_ID_10023",
CustomerEmail = "buyer@litecharms.co.za",
AmountGross = 350.00m,
AmountFee = 12.50m,
AmountNet = 337.50m,
PaymentStatus = "COMPLETE"
};
var result = await payfastService.WriteLedgerEntryAsync(request, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value > 0);
}
[IntegrationFact]
public async Task ValidateReferrerIpAsync_WithValidPayfastHostIp_ShouldReturnTrue()
{
var addresses = await Dns.GetHostAddressesAsync("sandbox.payfast.co.za", fixture.CancellationToken);
string liveTargetIp = addresses.First().ToString();
var result = await payfastService.ValidateReferrerIpAsync(liveTargetIp, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.True(result.Value);
}
[IntegrationFact]
public async Task ValidateReferrerIpAsync_WithUntrustedIp_ShouldReturnFalse()
{
string rogueIp = "8.8.8.8";
var result = await payfastService.ValidateReferrerIpAsync(rogueIp, true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.False(result.Value);
}
[IntegrationFact]
public void ValidatePaymentAmount_WhenWithinAllowableDelta_ShouldReturnTrue()
{
decimal systemExpectedTotal = 199.99m;
string gatewayClearedGross = "200.00"; // Variance is exactly R0.01
var result = payfastService.ValidatePaymentAmount(systemExpectedTotal, gatewayClearedGross);
Assert.True(result.IsSuccess);
Assert.True(result.Value);
}
[IntegrationFact]
public void ValidatePaymentAmount_WhenVarianceBreachesDeltaBounds_ShouldReturnFalse()
{
decimal systemExpectedTotal = 199.99m;
string gatewayClearedGross = "150.00";
var result = payfastService.ValidatePaymentAmount(systemExpectedTotal, gatewayClearedGross);
Assert.True(result.IsSuccess);
Assert.False(result.Value);
}
[IntegrationFact]
public async Task ValidateServerConfirmationAsync_WithUnrecognizedPayload_ShouldReturnFalseFromCentralGateway()
{
// Arrange - Execute against actual Payfast servers using raw mock parameters.
// The server handshake will return 200 OK with string payload 'INVALID'
string arbitraryParameters = "merchant_id=10000000&payment_status=COMPLETE";
var result = await payfastService.ValidateServerConfirmationAsync(arbitraryParameters, isSandbox: true, fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.False(result.Value); // Handshake data rejected as fraudulent/unrecognized
}
[IntegrationFact]
public void GenerateSignature_WithStandardTelemetryData_ShouldSucceedAndHashString()
{
var telemetryPayload = new Dictionary<string, string?>
{
{ "merchant_id", "10049307" },
{ "merchant_key", "ju6navn0jcbf0" },
{ "amount_gross", "250.00" },
{ "item_name", "Midrand School Textbook Variant A" }
};
string passphrase = "oauth_test_signature_pass";
var result = PayfastService.GenerateSignature(telemetryPayload, passphrase);
Assert.True(result.IsSuccess);
Assert.False(string.IsNullOrWhiteSpace(result.Value));
Assert.Equal(32, result.Value.Length); // MD5 outputs hex representations totaling 32 characters
}
}
@@ -1,6 +1,6 @@
using LiteCharms.Features.MidrandBooks.Payments; using LiteCharms.Features.MidrandBooks.Payments;
using LiteCharms.Features.MidrandBooks.Payments.Models; using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Tests.Common; using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
@@ -1,7 +1,7 @@
using LiteCharms.Features.MidrandBooks.Products; using LiteCharms.Features.MidrandBooks.Products;
using LiteCharms.Features.MidrandBooks.Products.Models; using LiteCharms.Features.MidrandBooks.Products.Models;
using LiteCharms.Features.MidrandBooks.Tests.Common;
using LiteCharms.Features.Models; using LiteCharms.Features.Models;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
@@ -1,28 +0,0 @@
{
"BookshopS3Settings": {
"ServiceUrl": "http://192.168.1.177:30900",
"Region": "garage",
"BucketName": "bookshop",
"CdnBaseUrl": "https://bookshop.cdn.khongisa.co.za"
},
"Email": {
"Credentials": {
"Username": "shop@litecharms.co.za"
},
"Port": 465,
"Host": "mail.litecharms.co.za",
"UseSsl": true
},
"Monitoring": {
"ApiKey": "",
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
"ServiceName": "LiteCharms.LeadGenerator"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -0,0 +1,3 @@
namespace LiteCharms.Features.MidrandBooks.Abstractions;
public interface IMidrandBooks;
@@ -58,6 +58,30 @@ public sealed class BooksService(IDbContextFactory<MidrandBooksDbContext> contex
} }
} }
public async ValueTask<Result<AuthorBook>> GetBookByProductIdAsync(long productId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var book = await context.Books
.AsNoTracking()
.Include(b => b.Author)
.Include(b => b.Product)
.ThenInclude(b => b!.Prices)
.Include(b => b.Pages)
.FirstOrDefaultAsync(b => b.ProductId == productId, cancellationToken);
return book is null
? Result.Fail<AuthorBook>(new Error($"Book with product ID {productId} not found"))
: Result.Ok(book.ToModel());
}
catch (Exception ex)
{
return Result.Fail<AuthorBook>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<AuthorBook>> GetBookAsync(long bookId, CancellationToken cancellationToken = default) public async ValueTask<Result<AuthorBook>> GetBookAsync(long bookId, CancellationToken cancellationToken = default)
{ {
try try
@@ -334,6 +334,28 @@ public sealed class CustomerService(IDbContextFactory<MidrandBooksDbContext> con
} }
} }
public async ValueTask<Result<Customer>> GetCustomerAsync(string email, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var customer = await context.Customers
.AsNoTracking()
.Include(c => c.Contacts)
.Include(c => c.Addresses)
.FirstOrDefaultAsync(c => c.Email == email, cancellationToken);
return customer is not null
? Result.Ok(customer.ToModel())
: Result.Fail<Customer>(new Error($"Customer with email '{email}' does not exist."));
}
catch (Exception ex)
{
return Result.Fail<Customer>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Customer>> GetCustomerAsync(long customerId, CancellationToken cancellationToken = default) public async ValueTask<Result<Customer>> GetCustomerAsync(long customerId, CancellationToken cancellationToken = default)
{ {
try try
@@ -12,8 +12,8 @@ public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
builder.Property(c => c.Company).IsRequired(false); builder.Property(c => c.Company).IsRequired(false);
builder.Property(c => c.VatNumber).IsRequired(false); builder.Property(c => c.VatNumber).IsRequired(false);
builder.Property(c => c.Email).IsRequired(); builder.Property(c => c.Email).IsRequired();
builder.Property(c => c.Phone).IsRequired(); builder.Property(c => c.Phone).IsRequired(false);
builder.Property(c => c.Website).IsRequired(); builder.Property(c => c.Website).IsRequired(false);
builder.Property(c => c.Enabled).HasDefaultValue(true); builder.Property(c => c.Enabled).HasDefaultValue(true);
builder.OwnsMany(f => f.SocialMedia, b => { b.ToJson(); }); builder.OwnsMany(f => f.SocialMedia, b => { b.ToJson(); });
@@ -6,12 +6,26 @@ using LiteCharms.Features.MidrandBooks.Orders.Models;
using LiteCharms.Features.MidrandBooks.Pages.Models; using LiteCharms.Features.MidrandBooks.Pages.Models;
using LiteCharms.Features.MidrandBooks.Payments.Models; using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Products.Models; using LiteCharms.Features.MidrandBooks.Products.Models;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace LiteCharms.Features.MidrandBooks.Extensions; namespace LiteCharms.Features.MidrandBooks.Extensions;
public static class Mappers public static class Mappers
{ {
public static PaymentGatewayLedger ToModel(this Payments.Entities.PaymentGatewayLedger entity) => new()
{
Id = entity.Id,
CreatedAt = entity.CreatedAt,
CustomerEmail = entity.CustomerEmail,
OrderId = entity.OrderId,
PaymentId = entity.PaymentId,
MerchantPaymentId = entity.MerchantPaymentId,
PayfastPaymentId = entity.PayfastPaymentId,
PaymentStatus = entity.PaymentStatus,
AmountGross = entity.AmountGross,
AmountFee = entity.AmountFee,
AmountNet = entity.AmountNet
};
public static Refund ToModel(this Payments.Entities.Refund entity) => new() public static Refund ToModel(this Payments.Entities.Refund entity) => new()
{ {
CreatedAt = entity.CreatedAt, CreatedAt = entity.CreatedAt,
@@ -30,10 +44,9 @@ public static class Mappers
CreatedAt = entity.CreatedAt, CreatedAt = entity.CreatedAt,
CustomerId = entity.CustomerId, CustomerId = entity.CustomerId,
OrderId = entity.OrderId, OrderId = entity.OrderId,
PaymentGatewayId = entity.PaymentGatewayId,
PaymentGatewayReference = entity.PaymentGatewayReference,
PaymentId = entity.PaymentId, PaymentId = entity.PaymentId,
Status = entity.Status, Status = entity.Status,
MerchantPaymentId = entity.MerchantPaymentId,
}; };
public static PaymentGateway ToModel(this Payments.Entities.PaymentGateway entity) => new() public static PaymentGateway ToModel(this Payments.Entities.PaymentGateway entity) => new()
@@ -1,18 +1,27 @@
using LiteCharms.Features.Abstractions; using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Browser;
using LiteCharms.Features.MidrandBooks.Abstractions;
namespace LiteCharms.Features.MidrandBooks.Extensions; namespace LiteCharms.Features.MidrandBooks.Extensions;
public static class Shop public static class Shop
{ {
public static IServiceCollection AddShopServices(this IServiceCollection services) public static IServiceCollection AddShopServices(this IServiceCollection services, bool includeLocalStorage = false)
{ {
var serviceType = typeof(IService); var serviceType = typeof(IService);
var implementations = Assembly.GetExecutingAssembly().GetTypes() var sharedImplementations = typeof(IFeatures).Assembly.GetTypes()
.Where(t => serviceType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract); .Where(t => serviceType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
foreach (var implementation in implementations) foreach (var sharedImplementation in sharedImplementations) services.AddScoped(sharedImplementation);
services.AddScoped(implementation);
var coreImplementations = typeof(IMidrandBooks).Assembly.GetTypes()
.Where(t => serviceType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
foreach (var coreImplementation in coreImplementations) services.AddScoped(coreImplementation);
if (includeLocalStorage)
services.AddScoped<LocalStorageService>();
return services; return services;
} }
@@ -32,11 +32,11 @@
<!-- Quartz Scheduler--> <!-- Quartz Scheduler-->
<ItemGroup> <ItemGroup>
<PackageReference Include="Humanizer" Version="3.0.10" /> <PackageReference Include="Humanizer" Version="3.0.10" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.98"> <PackageReference Include="Meziantou.Analyzer" Version="3.0.104">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="OpenTelemetry" Version="1.15.3" /> <PackageReference Include="OpenTelemetry" Version="1.16.0" />
<PackageReference Include="Quartz" Version="3.18.1" /> <PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" /> <PackageReference Include="Quartz.Plugins" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" /> <PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
@@ -50,11 +50,11 @@
<!-- Configuration --> <!-- Configuration -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" /> <Using Include="Microsoft.Extensions.Configuration" />
@@ -67,9 +67,9 @@
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" /> <Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
@@ -78,12 +78,12 @@
<!-- Open Telemetry --> <!-- Open Telemetry -->
<ItemGroup> <ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.16.0" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" /> <Using Include="OpenTelemetry.Resources" />
@@ -95,13 +95,13 @@
<!-- Database --> <!-- Database -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
@@ -136,8 +136,8 @@
<!-- Amazon S3 SDK --> <!-- Amazon S3 SDK -->
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.1" /> <PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.7" />
<PackageReference Include="AWSSDK.S3" Version="4.0.23.4" /> <PackageReference Include="AWSSDK.S3" Version="4.0.24.4" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" /> <ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
<!-- global Usings --> <!-- global Usings -->
@@ -148,6 +148,11 @@
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="Microsoft.AspNetCore.Http" />
<Using Include="System.Net.Sockets" />
<Using Include="System.Text.RegularExpressions" />
<Using Include="System.Web" />
<Using Include="System.Net" />
<Using Include="Humanizer" /> <Using Include="Humanizer" />
<Using Include="System.Globalization" /> <Using Include="System.Globalization" />
<Using Include="System.Reflection" /> <Using Include="System.Reflection" />
@@ -43,7 +43,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
if (!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken)) if (!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken))
return Result.Fail<long>("Order not found."); return Result.Fail<long>("Order not found.");
if(!await context.Books.AnyAsync(ab => ab.Id == request.AuthorBookId, cancellationToken)) if (!await context.Books.AnyAsync(ab => ab.Id == request.AuthorBookId, cancellationToken))
return Result.Fail<long>("Author book not found."); return Result.Fail<long>("Author book not found.");
if (!await context.Prices.AnyAsync(pp => pp.Id == request.ProductPriceId, cancellationToken)) if (!await context.Prices.AnyAsync(pp => pp.Id == request.ProductPriceId, cancellationToken))
@@ -51,7 +51,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
var existingItem = await context.OrderItems.FirstOrDefaultAsync(i => i.ProductPriceId == request.ProductPriceId && i.OrderId == orderId, cancellationToken); var existingItem = await context.OrderItems.FirstOrDefaultAsync(i => i.ProductPriceId == request.ProductPriceId && i.OrderId == orderId, cancellationToken);
if(existingItem is not null) if (existingItem is not null)
{ {
existingItem.Quantity += request.Quantity; existingItem.Quantity += request.Quantity;
@@ -82,7 +82,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{ {
try try
{ {
if(items.Length == 0) if (items.Length == 0)
return Result.Fail("No items to add."); return Result.Fail("No items to add.");
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
@@ -164,6 +164,27 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
public async ValueTask<Result> CancelOrderAsync(long orderId, CancellationToken cancellationToken = default) => public async ValueTask<Result> CancelOrderAsync(long orderId, CancellationToken cancellationToken = default) =>
await UpdateOrderStatusAsync(orderId, OrderStatus.Cancelled, cancellationToken); await UpdateOrderStatusAsync(orderId, OrderStatus.Cancelled, cancellationToken);
public async ValueTask<Result<Order>> GetPendingOrderAsync(long customerId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var order = await context.Orders.AsNoTracking()
.Where(o => o.Status == OrderStatus.Pending && o.CustomerId == customerId)
.OrderByDescending(o => o.Id)
.FirstOrDefaultAsync(cancellationToken);
return order is not null
? Result.Ok(order.ToModel())
: Result.Fail<Order>("Order not found.");
}
catch (Exception ex)
{
return Result.Fail<Order>(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Order>> GetOrderAsync(long orderId, CancellationToken cancellationToken = default) public async ValueTask<Result<Order>> GetOrderAsync(long orderId, CancellationToken cancellationToken = default)
{ {
try try
@@ -182,13 +203,33 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
} }
} }
public async ValueTask<Result<OrderItem[]>> GetOrderItemsAsync(long orderId, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var orderItems = await context.OrderItems
.Where(o => o.OrderId == orderId)
.ToListAsync(cancellationToken);
return orderItems.Count > 0
? Result.Ok(orderItems.Select(i => i.ToModel()).ToArray())
: Result.Fail<OrderItem[]>($"Order items not found for order ID {orderId}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Order[]>> GetOrdersByCustomerAsync(long customerId, CancellationToken cancellationToken = default) public async ValueTask<Result<Order[]>> GetOrdersByCustomerAsync(long customerId, CancellationToken cancellationToken = default)
{ {
try try
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken)) if (!await context.Customers.AnyAsync(c => c.Id == customerId, cancellationToken))
return Result.Fail<Order[]>("Customer not found."); return Result.Fail<Order[]>("Customer not found.");
var orders = await context.Orders var orders = await context.Orders
@@ -255,16 +296,16 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken)) if (!await context.Orders.AnyAsync(o => o.Id == orderId, cancellationToken))
return Result.Fail("Order not found."); return Result.Fail("Order not found.");
if(!await context.Addresses.AnyAsync(a => a.Id == request.AddressId, cancellationToken)) if (!await context.Addresses.AnyAsync(a => a.Id == request.AddressId, cancellationToken))
return Result.Fail("Address not found."); return Result.Fail("Address not found.");
if(!await context.ShippingProviders.AnyAsync(sp => sp.Id == request.ShippingProviderId && sp.Enabled, cancellationToken)) if (!await context.ShippingProviders.AnyAsync(sp => sp.Id == request.ShippingProviderId && sp.Enabled, cancellationToken))
return Result.Fail("Shipping provider not found or disabled."); return Result.Fail("Shipping provider not found or disabled.");
if(await context.Shippings.AnyAsync(s => s.OrderId == orderId, cancellationToken)) if (await context.Shippings.AnyAsync(s => s.OrderId == orderId, cancellationToken))
return Result.Fail("Shipping already exists for this order."); return Result.Fail("Shipping already exists for this order.");
var shipping = context.Shippings.Add(new Entities.Shipping var shipping = context.Shippings.Add(new Entities.Shipping
@@ -334,7 +375,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
try try
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var rowsDeleted = await context.Shippings var rowsDeleted = await context.Shippings
.Where(s => s.Id == shippingId && s.OrderId == orderId) .Where(s => s.Id == shippingId && s.OrderId == orderId)
.ExecuteDeleteAsync(cancellationToken); .ExecuteDeleteAsync(cancellationToken);
@@ -377,7 +418,7 @@ public sealed class OrderService(IDbContextFactory<MidrandBooksDbContext> contex
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(await context.ShippingProviders.AnyAsync(sp => sp.Type == request.Type, cancellationToken)) if (await context.ShippingProviders.AnyAsync(sp => sp.Type == request.Type, cancellationToken))
return Result.Fail("Shipping provider with the same type already exists."); return Result.Fail("Shipping provider with the same type already exists.");
var shippingProvider = context.ShippingProviders.Add(new Entities.ShippingProvider var shippingProvider = context.ShippingProviders.Add(new Entities.ShippingProvider
@@ -0,0 +1,153 @@
using LiteCharms.Features.Browser;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Products.Models;
namespace LiteCharms.Features.MidrandBooks.Payments;
public sealed class CartService(LocalStorageService localStorage)
{
private readonly string CartStorageKey = HashService.ToMd5Hash(nameof(Cart)).Value;
public Cart ShoppingCart { get; private set; } = new();
public event Action? OnCartChanged;
public static Func<Cart, long, int> GetCartItemQuantity = (shoppingCart, productPriceId) =>
shoppingCart.Items.FirstOrDefault(p => p.Price!.Id == productPriceId)?.Quantity ?? 1;
public Cart GetCart() => ShoppingCart;
public void NotifyStateChanged() => OnCartChanged?.Invoke();
public async Task LoadCartFromStorageAsync()
{
var loadResult = await localStorage.GetAsync<Cart>(CartStorageKey);
if (loadResult.IsFailed) await localStorage.SaveAsync(CartStorageKey, ShoppingCart);
if (loadResult.IsSuccess) ShoppingCart = loadResult.Value;
NotifyStateChanged();
}
public async Task SaveCartToStorageAsync() => await localStorage.SaveAsync(CartStorageKey, ShoppingCart);
public void AddItem(ProductPrice productPrice, Product product, Author author)
{
var itemExists = false;
for (var i = 0; i < ShoppingCart.Items.Count; i++)
{
if (ShoppingCart.Items[i].Price!.Id == productPrice.Id)
{
ShoppingCart.Items[i].Quantity++;
ShoppingCart.Items[i].Amount += productPrice.Amount;
itemExists = true;
break;
}
}
if (!itemExists)
ShoppingCart.Items.Add(new CartItem
{
Product = product,
Author = author,
Price = productPrice,
Amount = productPrice.Amount,
Quantity = 1,
});
CalculateTotalPrice();
NotifyStateChanged();
}
public void UpdateQuantity(long productPriceId, int delta)
{
for (var i = 0; i < ShoppingCart.Items.Count; i++)
{
if (ShoppingCart.Items[i].Price!.Id == productPriceId)
{
var oldQuantity = ShoppingCart.Items[i].Quantity;
var pricePerUnit = ShoppingCart.Items[i].Price!.Amount;
ShoppingCart.Items[i].Quantity += delta;
ShoppingCart.Items[i].Amount = pricePerUnit * ShoppingCart.Items[i].Quantity;
break;
}
}
CalculateTotalPrice();
NotifyStateChanged();
}
public void RemoveOneItem(long productPriceId)
{
for (var i = 0; i < ShoppingCart.Items.Count; i++)
{
if (ShoppingCart.Items[i].Price!.Id == productPriceId)
{
if (ShoppingCart.Items[i].Quantity <= 1)
{
ShoppingCart.Items.Remove(ShoppingCart.Items[i]);
break;
}
else
{
ShoppingCart.Items[i].Quantity--;
ShoppingCart.Items[i].Amount -= ShoppingCart.Items[i].Price!.Amount;
}
break;
}
}
CalculateTotalPrice();
NotifyStateChanged();
}
public void RemoveAllSameItem(long productPriceId)
{
if (ShoppingCart.Items.Count == 0) return;
var item = ShoppingCart.Items.FirstOrDefault(i => i.Price?.Id == productPriceId);
if (item is not null) ShoppingCart.Items.Remove(item);
CalculateTotalPrice();
NotifyStateChanged();
}
public void Clear()
{
if(ShoppingCart.CustomerId is not null || ShoppingCart.OrderId is not null)
{
ShoppingCart.TotalAmount = 0;
ShoppingCart.TotalVat = 0;
ShoppingCart.Items.Clear();
return;
}
ShoppingCart = new Cart();
NotifyStateChanged();
}
public decimal CalculateTotalPrice()
{
if (ShoppingCart.Items.Count == 0) return 0;
var gross = ShoppingCart.Items.Sum(i => i.Amount);
if (!ShoppingCart.IsVatInclusive) ShoppingCart.TotalVat = gross * ShoppingCart.VatRate;
ShoppingCart.TotalAmount = gross + ShoppingCart.TotalVat;
return ShoppingCart.TotalAmount;
}
}
@@ -1,4 +1,4 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Entities; namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
[EntityTypeConfiguration<PaymentGatewayConfiguration, PaymentGateway>] [EntityTypeConfiguration<PaymentGatewayConfiguration, PaymentGateway>]
public class PaymentGateway : Models.PaymentGateway; public sealed class PaymentGateway : Models.PaymentGateway;
@@ -0,0 +1,11 @@
using LiteCharms.Features.MidrandBooks.Orders.Entities;
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
[EntityTypeConfiguration<PaymentGatewayLedgerConfiguration, PaymentGatewayLedger>]
public class PaymentGatewayLedger : Models.PaymentGatewayLedger
{
public virtual Order? Order { get; set; }
public virtual Payment? Payment { get; set; }
}
@@ -0,0 +1,30 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Entities;
public sealed class PaymentGatewayLedgerConfiguration : IEntityTypeConfiguration<PaymentGatewayLedger>
{
public void Configure(EntityTypeBuilder<PaymentGatewayLedger> builder)
{
builder.ToTable("GatewayLedger");
builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.OrderId).IsRequired();
builder.Property(f => f.PaymentId).IsRequired();
builder.Property(f => f.PayfastPaymentId).IsRequired();
builder.Property(f => f.MerchantPaymentId).IsRequired();
builder.Property(f => f.AmountGross).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.AmountFee).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.AmountNet).IsRequired().HasPrecision(18, 2);
builder.Property(f => f.CustomerEmail).IsRequired(false);
builder.HasOne(f => f.Order)
.WithMany()
.HasForeignKey(f => f.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(f => f.Payment)
.WithMany()
.HasForeignKey(f => f.PaymentId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -11,6 +11,4 @@ public class PaymentLedger : Models.PaymentLedger
public virtual Order? Order { get; set; } public virtual Order? Order { get; set; }
public virtual Customer? Customer { get; set; } public virtual Customer? Customer { get; set; }
public virtual PaymentGateway? Gateway { get; set; }
} }
@@ -9,8 +9,7 @@ public sealed class PaymentLedgerConfiguration : IEntityTypeConfiguration<Paymen
builder.HasKey(f => f.Id); builder.HasKey(f => f.Id);
builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()"); builder.Property(f => f.CreatedAt).IsRequired().ValueGeneratedOnAdd().HasDefaultValueSql("now()");
builder.Property(f => f.Status).IsRequired(); builder.Property(f => f.Status).IsRequired();
builder.Property(f => f.PaymentGatewayReference).IsRequired(false); builder.Property(f => f.MerchantPaymentId).IsRequired(false);
builder.Property(f => f.PaymentGatewayId).IsRequired(false);
builder.Property(f => f.OrderId).IsRequired(); builder.Property(f => f.OrderId).IsRequired();
builder.Property(f => f.CustomerId).IsRequired(); builder.Property(f => f.CustomerId).IsRequired();
builder.Property(f => f.PaymentId).IsRequired(); builder.Property(f => f.PaymentId).IsRequired();
@@ -31,11 +30,5 @@ public sealed class PaymentLedgerConfiguration : IEntityTypeConfiguration<Paymen
.WithMany() .WithMany()
.IsRequired() .IsRequired()
.HasForeignKey(f => f.CustomerId); .HasForeignKey(f => f.CustomerId);
builder.HasOne(f => f.Gateway)
.WithMany()
.IsRequired(false)
.HasForeignKey(f => f.PaymentGatewayId)
.OnDelete(DeleteBehavior.Cascade);
} }
} }
@@ -1,81 +1,104 @@
using LiteCharms.Features.Hasher; using LiteCharms.Features.Hasher;
using LiteCharms.Features.Mediator;
using LiteCharms.Features.MidrandBooks.Orders; using LiteCharms.Features.MidrandBooks.Orders;
using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace LiteCharms.Features.MidrandBooks.Payments.Events.Handlers; namespace LiteCharms.Features.MidrandBooks.Payments.Events.Handlers;
public sealed class PayfastPaymentConfirmationReceivedEventHandler(PaymentService paymentService, public sealed class PayfastPaymentConfirmationReceivedEventHandler(IServiceProvider services, ILogger<PayfastPaymentConfirmationReceivedEvent> logger) :
HashService hashService, OrderService orderService, ILogger<PayfastPaymentConfirmationReceivedEvent> logger) :
INotificationHandler<PayfastPaymentConfirmationReceivedEvent> INotificationHandler<PayfastPaymentConfirmationReceivedEvent>
{ {
public async ValueTask Handle(PayfastPaymentConfirmationReceivedEvent notification, CancellationToken cancellationToken) public async ValueTask Handle(PayfastPaymentConfirmationReceivedEvent notification, CancellationToken cancellationToken)
{ {
var hashResult = hashService.DecodeLongIdHash(notification.Payload?.MPaymentId!); using var activity = MediatorTelemetry.Source.StartActivity($"Quartz: {typeof(PayfastPaymentConfirmationReceivedEvent).Name}");
if (hashResult.IsFailed) activity?.SetTag("event.correlation_id", notification.CorrelationId);
{
logger.LogError("Failed to decode payment ID hash: {Hash}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", hashResult.Errors.Select(e => e.Message))); await using var scope = services.CreateAsyncScope();
throw new Exception($"Failed to decode payment ID hash: {notification.Payload?.MPaymentId}."); var hashService = scope.ServiceProvider.GetRequiredService<HashService>();
} var orderService = scope.ServiceProvider.GetRequiredService<OrderService>();
var paymentService = scope.ServiceProvider.GetRequiredService<PaymentService>();
var payfastService = scope.ServiceProvider.GetRequiredService<PayfastService>();
var payload = notification.Payload ?? throw new Exception("Payload metadata context is null.");
var hashResult = hashService.DecodeLongIdHash(payload.MerchantPaymentId!);
if (hashResult.IsFailed) throw new Exception("Failed to decode application tracking hash key identifier.");
var orderResult = await orderService.GetOrderAsync(hashResult.Value, cancellationToken); var orderResult = await orderService.GetOrderAsync(hashResult.Value, cancellationToken);
if (orderResult.IsFailed) if (orderResult.IsFailed) throw new Exception("Target system order entity context cannot be traced.");
{
logger.LogError("Failed to retrieve order for payment ID: {PaymentId}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", orderResult.Errors.Select(e => e.Message)));
throw new Exception($"Failed to retrieve order for payment ID: {notification.Payload?.MPaymentId}.");
}
var paymentResult = await paymentService.GetOrderPaymentAsync(orderResult.Value.CustomerId, cancellationToken); var paymentResult = await paymentService.GetOrderPaymentAsync(orderResult.Value.Id, cancellationToken);
if (paymentResult.IsFailed) if (paymentResult.IsFailed) throw new Exception("Target payment ledger entity cannot be resolved.");
{
logger.LogError("Failed to retrieve payment for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", paymentResult.Errors.Select(e => e.Message)));
throw new Exception($"Failed to retrieve payment for order ID: {orderResult.Value.Id}.");
}
var isAlreadyProcessed = await paymentService.HasLedgerEntryAsync(orderResult.Value.Id, paymentResult.Value.Id, 1, cancellationToken);
if (isAlreadyProcessed.IsFailed)
{
logger.LogError("Failed to check existing ledger entry for order ID: {OrderId} and payment ID: {PaymentId}. Errors: {Errors}", orderResult.Value.Id, paymentResult.Value.Id, string.Join(", ", isAlreadyProcessed.Errors.Select(e => e.Message)));
throw new Exception($"Failed to check existing ledger entry for order ID: {orderResult.Value.Id} and payment ID: {paymentResult.Value.Id}.");
}
var isAlreadyProcessed = await paymentService.HasLedgerEntryAsync(orderResult.Value.Id, paymentResult.Value.Id, cancellationToken);
if (isAlreadyProcessed.Value) if (isAlreadyProcessed.Value)
{ {
logger.LogInformation("Payment confirmation for payment ID: {PaymentId} has already been processed. Skipping.", notification.Payload?.MPaymentId); logger.LogWarning("Webhook reference token '{Ref}' already verified. Skipping processing routines.", payload.MerchantPaymentId);
return; return;
} }
var ledgerResult = await paymentService.WriteLedgerEntryAsync(new Models.CreateLedgerEntry var isAmountValid = payfastService.ValidatePaymentAmount(orderResult.Value.Total, payload.AmountGross);
if (!isAmountValid.Value)
throw new Exception("Security validation exception: Transaction cost variance bounds breached (Price Tampering Detected).");
decimal.TryParse(payload.AmountGross, CultureInfo.InvariantCulture, out var gross);
decimal.TryParse(payload.AmountFee, CultureInfo.InvariantCulture, out var fee);
decimal.TryParse(payload.AmountNet, CultureInfo.InvariantCulture, out var net);
string status = payload.PaymentStatus ?? "UNKNOWN";
await payfastService.WriteLedgerEntryAsync(new CreateGatewayLedgerEntry
{ {
CustomerId = orderResult.Value.CustomerId,
OrderId = orderResult.Value.Id, OrderId = orderResult.Value.Id,
PaymentId = paymentResult.Value.Id, PaymentId = paymentResult.Value.Id,
Status = LedgerStatuses.Received, MerchantPaymentId = payload.MerchantPaymentId!,
PaymentGatewayId = 1, PayfastPaymentId = payload.PaymentId,
PaymentGatewayReference = notification.CorrelationId, CustomerEmail = payload.EmailAddress,
AmountFee = fee,
AmountGross = gross,
AmountNet = net,
PaymentStatus = status,
}, cancellationToken); }, cancellationToken);
if (ledgerResult.IsFailed) if (status.Equals("COMPLETE", StringComparison.OrdinalIgnoreCase))
{ {
logger.LogError("Failed to write ledger entry for payment ID: {PaymentId}. Errors: {Errors}", notification.Payload?.MPaymentId, string.Join(", ", ledgerResult.Errors.Select(e => e.Message))); var ledgerWriteResult = await paymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
throw new Exception($"Failed to write ledger entry for payment ID: {notification.Payload?.MPaymentId}."); {
OrderId = orderResult.Value.Id,
PaymentId = paymentResult.Value.Id,
PaymentGatewayReference = payload.MerchantPaymentId!,
Status = LedgerStatuses.Completed,
CustomerId = orderResult.Value.CustomerId,
}, cancellationToken);
if (ledgerWriteResult.IsFailed) throw new Exception("Failed to write ledger entry for payment confirmation.");
var completePaymentResult = await paymentService.CompletePaymentAsync(paymentResult.Value.Id, PaymentStatuses.Paid, cancellationToken);
if (completePaymentResult.IsFailed) throw new Exception("Failed to update payment status to 'Paid'.");
var updateOrderResult = await orderService.UpdateOrderStatusAsync(orderResult.Value.Id, OrderStatus.Completed, cancellationToken);
if (updateOrderResult.IsFailed) throw new Exception("Failed to update order status to 'Completed'.");
logger.LogInformation("Order payment verified secure and cleared successfully.");
}
else
{
LedgerStatuses ledgerStatus = status.Equals("CANCELLED", StringComparison.OrdinalIgnoreCase)
? LedgerStatuses.Cancelled
: LedgerStatuses.Failed;
await paymentService.WriteLedgerEntryAsync(new CreateLedgerEntry
{
OrderId = orderResult.Value.Id,
PaymentId = paymentResult.Value.Id,
PaymentGatewayReference = payload.MerchantPaymentId!,
Status = ledgerStatus,
CustomerId = orderResult.Value.CustomerId,
}, cancellationToken);
logger.LogInformation("Webhook pipeline logged non-success entry to ledger with status: {Status}", status);
} }
var paymentCompletedResult = await paymentService.CompletePaymentAsync(paymentResult.Value.Id, PaymentStatuses.Paid, cancellationToken); activity?.SetStatus(ActivityStatusCode.Ok);
if (paymentCompletedResult.IsFailed)
{
logger.LogError("Failed to complete payment for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", paymentCompletedResult.Errors.Select(e => e.Message)));
throw new Exception($"Failed to complete payment for order ID: {orderResult.Value.Id}.");
}
var orderCompletedResult = await orderService.UpdateOrderStatusAsync(orderResult.Value.Id, OrderStatus.Completed, cancellationToken);
if (orderCompletedResult.IsFailed)
{
logger.LogError("Failed to update order status to Completed for order ID: {OrderId}. Errors: {Errors}", orderResult.Value.Id, string.Join(", ", orderCompletedResult.Errors.Select(e => e.Message)));
throw new Exception($"Failed to update order status to Completed for order ID: {orderResult.Value.Id}.");
}
logger.LogInformation("Received Payfast payment confirmation for payment ID: {PaymentId}", notification.Payload?.MPaymentId);
// TODO: Publish MediatR notifications or queue downstream Quartz jobs (Discord, Shipping, Customer Email, Royalties)
} }
} }
@@ -1,5 +1,5 @@
using LiteCharms.Features.Abstractions; using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Models; using LiteCharms.Features.MidrandBooks.Payments.Models;
namespace LiteCharms.Features.MidrandBooks.Payments.Events; namespace LiteCharms.Features.MidrandBooks.Payments.Events;
@@ -9,14 +9,22 @@ public sealed class PayfastPaymentConfirmationReceivedEvent : EventBase, IEvent
public PayfastWebhookPayload? Payload { get; set; } public PayfastWebhookPayload? Payload { get; set; }
public string? RemoteIpAddress { get; set; }
public bool PerformBackgroundChecks { get; set; }
public bool AllowLoopback { get; set; }
public PayfastPaymentConfirmationReceivedEvent() { } public PayfastPaymentConfirmationReceivedEvent() { }
private PayfastPaymentConfirmationReceivedEvent(PayfastWebhookPayload? payload, string paymentId) private PayfastPaymentConfirmationReceivedEvent(PayfastWebhookPayload? payload, string paymentId, bool performBackgroundChecks = true, bool allowLoopback = false)
{ {
Payload = payload; Payload = payload;
CorrelationId = paymentId; CorrelationId = paymentId;
PerformBackgroundChecks = performBackgroundChecks;
AllowLoopback = allowLoopback;
} }
public static PayfastPaymentConfirmationReceivedEvent Create(PayfastWebhookPayload? payload, string paymentId) => public static PayfastPaymentConfirmationReceivedEvent Create(PayfastWebhookPayload? payload, string paymentId, bool performBackgroundChecks = true, bool allowLoopback = false) =>
new(payload, paymentId); new(payload, paymentId, performBackgroundChecks, allowLoopback);
} }
@@ -0,0 +1,18 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed class Cart
{
public long? CustomerId { get; set; }
public long? OrderId { get; set; }
public decimal TotalAmount { get; set; }
public decimal TotalVat { get; set; }
public decimal VatRate { get; set; } = 0.15m;
public bool IsVatInclusive { get; set; } = true;
public IList<CartItem> Items { get; set; } = [];
}
@@ -0,0 +1,17 @@
using LiteCharms.Features.MidrandBooks.Authors.Models;
using LiteCharms.Features.MidrandBooks.Products.Models;
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed class CartItem
{
public Author? Author { get; set; }
public Product? Product { get; set; }
public ProductPrice? Price { get; set; }
public int Quantity { get; set; }
public decimal Amount { get; set; }
}
@@ -0,0 +1,59 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed class PayfastWebhookPayload
{
public string? MerchantId { get; set; }
public string? MerchantKey { get; set; }
public string? Signature { get; set; }
public string? MerchantPaymentId { get; set; }
public string? PaymentId { get; set; }
public string? PaymentStatus { get; set; }
public string? ItemName { get; set; }
public string? ItemDescription { get; set; }
public string? AmountGross { get; set; }
public string? AmountFee { get; set; }
public string? AmountNet { get; set; }
public string? NameFirst { get; set; }
public string? NameLast { get; set; }
public string? EmailAddress { get; set; }
public string? CustomStr1 { get; set; }
public string? CustomInt1 { get; set; }
public string? Token { get; set; }
public IDictionary<string, string?> ToParamDictionary() => new Dictionary<string, string?>
(StringComparer.Ordinal)
{
{ "merchant_id", MerchantId },
{ "merchant_key", MerchantKey },
{ "m_payment_id", MerchantPaymentId },
{ "pf_payment_id", PaymentId },
{ "payment_status", PaymentStatus },
{ "item_name", ItemName },
{ "item_description", ItemDescription },
{ "amount_gross", AmountGross },
{ "amount_fee", AmountFee },
{ "amount_net", AmountNet },
{ "custom_str1", CustomStr1 },
{ "custom_int1", CustomInt1 },
{ "name_first", NameFirst },
{ "name_last", NameLast },
{ "email_address", EmailAddress },
{ "token", Token }
};
}
@@ -0,0 +1,26 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public class PaymentGatewayLedger
{
public long Id { get; set; }
public DateTime CreatedAt { get; set; }
public string? CustomerEmail { get; set; }
public long OrderId { get; set; }
public long PaymentId { get; set; }
public string? MerchantPaymentId { get; set; }
public string? PayfastPaymentId { get; set; }
public string? PaymentStatus { get; set; }
public decimal AmountGross { get; set; }
public decimal AmountFee { get; set; }
public decimal AmountNet { get; set; }
}
@@ -14,7 +14,5 @@ public class PaymentLedger
public long CustomerId { get; set; } public long CustomerId { get; set; }
public string? PaymentGatewayReference { get; set; } public string? MerchantPaymentId { get; set; }
public long? PaymentGatewayId { get; set; }
} }
@@ -1,5 +1,26 @@
namespace LiteCharms.Features.MidrandBooks.Payments.Models; namespace LiteCharms.Features.MidrandBooks.Payments.Models;
public sealed record CreateGatewayLedgerEntry
{
public string? CustomerEmail { get; set; }
public required long OrderId { get; set; }
public required long PaymentId { get; set; }
public string? MerchantPaymentId { get; set; }
public string? PayfastPaymentId { get; set; }
public string? PaymentStatus { get; set; }
public decimal AmountGross { get; set; }
public decimal AmountFee { get; set; }
public decimal AmountNet { get; set; }
}
public sealed record UpdateRefund public sealed record UpdateRefund
{ {
public long OrderId { get; set; } public long OrderId { get; set; }
@@ -0,0 +1,245 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Hasher;
using LiteCharms.Features.MidrandBooks.Payments.Models;
using LiteCharms.Features.MidrandBooks.Postgres;
namespace LiteCharms.Features.MidrandBooks.Payments;
public sealed partial class PayfastService(IDbContextFactory<MidrandBooksDbContext> contextFactory,
IOptions<PayfastSettings> payfastOptions, ILogger<PayfastService> logger, IHttpClientFactory httpClientFactory) : IService
{
[GeneratedRegex(@"%[0-9A-Fa-f]{2}", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
public static partial Regex PercentEncodingRegex { get; }
public async ValueTask<Result<long>> WriteLedgerEntryAsync(CreateGatewayLedgerEntry request, CancellationToken cancellationToken = default)
{
try
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if(!await context.Orders.AnyAsync(o => o.Id == request.OrderId, cancellationToken))
return Result.Fail<long>("Referenced order ID does not exist in database.");
if(!await context.Payments.AnyAsync(p => p.Id == request.PaymentId, cancellationToken))
return Result.Fail<long>("Referenced payment ID does not exist in database.");
var entry = context.GatewayLedger.Add(new Entities.PaymentGatewayLedger
{
CustomerEmail = request.CustomerEmail,
OrderId = request.OrderId,
PaymentId = request.PaymentId,
MerchantPaymentId = request.MerchantPaymentId,
PayfastPaymentId = request.PayfastPaymentId,
PaymentStatus = request.PaymentStatus,
AmountGross = request.AmountGross,
AmountFee = request.AmountFee,
AmountNet = request.AmountNet,
CreatedAt = DateTime.UtcNow,
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok(entry.Entity.Id)
: Result.Fail<long>("Failed to save Payfast ledger entry to database.");
}
catch (Exception ex)
{
return Result.Fail<long>(new Error("Failed to write Payfast ledger entry to database.").CausedBy(ex));
}
}
public static bool VerifyIncomingSignatureFromForm(IFormCollection formCollection, string passphrase)
{
var sortedFields = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var field in formCollection)
{
sortedFields.Add(field.Key, field.Value.ToString());
}
if (!sortedFields.TryGetValue("signature", out var incomingSignature)) return false;
var stringBuilder = new StringBuilder();
foreach (var key in sortedFields.Keys)
{
if (key.Equals("signature", StringComparison.OrdinalIgnoreCase)) continue;
string encodedVal = HttpUtility.UrlEncode(sortedFields[key].Trim());
string cleanVal = PercentEncodingRegex.Replace(encodedVal, m => m.Value.ToUpperInvariant());
stringBuilder.Append($"{key}={cleanVal}&");
}
string encodedPassphrase = HttpUtility.UrlEncode(passphrase.Trim());
string safePassphrase = PercentEncodingRegex.Replace(encodedPassphrase, m => m.Value.ToUpperInvariant());
stringBuilder.Append($"passphrase={safePassphrase}");
string generatedSignature = HashService.ToMd5Hash(stringBuilder.ToString()).Value;
return incomingSignature.Equals(generatedSignature, StringComparison.OrdinalIgnoreCase);
}
public async ValueTask<Result<bool>> ValidateReferrerIpAsync(string remoteIpAddress, bool allowLoopback = false, CancellationToken cancellationToken = default)
{
if(payfastOptions.Value?.ValidHosts?.Length == 0)
return Result.Fail<bool>("Valid payfast hosts not configured.");
if (string.IsNullOrWhiteSpace(remoteIpAddress))
return Result.Fail<bool>("Remote IP address is null or whitespace.");
try
{
var validIps = new HashSet<IPAddress>();
foreach (var host in payfastOptions.Value!.ValidHosts!)
{
try
{
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
foreach (var addr in addresses) validIps.Add(addr);
}
catch (SocketException ex)
{
logger.LogWarning(ex, "DNS warning: Failed to resolve Payfast node '{Host}'. It may be decommissioned or unreachable.", host);
}
}
if (IPAddress.TryParse(remoteIpAddress, out var incomingIp))
{
if (allowLoopback && IPAddress.IsLoopback(incomingIp))
{
logger.LogInformation("Local development loopback IP '{RemoteIp}' allowed bypassing DNS verification.", remoteIpAddress);
return Result.Ok(true);
}
bool isValid = validIps.Contains(incomingIp);
if (!isValid)
logger.LogWarning("SECURITY ALERT: Webhook IP '{RemoteIp}' originated from an unlisted host schema.", remoteIpAddress);
return Result.Ok(isValid);
}
return Result.Fail<bool>("Invalid remote IP address format.");
}
catch (Exception ex)
{
return Result.Fail<bool>(new Error("DNS Verification error while scanning Payfast IP nodes.").CausedBy(ex));
}
}
public Result<bool> ValidatePaymentAmount(decimal expectedTotal, string? amountGrossString)
{
if (!decimal.TryParse(amountGrossString, CultureInfo.InvariantCulture, out decimal grossAmount))
return Result.Fail<bool>("Failed to parse payment amount.");
decimal delta = Math.Abs(expectedTotal - grossAmount);
bool isAmountValid = delta <= 0.01m;
if (!isAmountValid)
logger.LogError("FINANCIAL DRIFT EXCEPTION: Expected order total R{Expected} but gateway cleared R{Cleared}.", expectedTotal, grossAmount);
return Result.Ok(isAmountValid);
}
public async ValueTask<Result<bool>> ValidateServerConfirmationAsync(string rawQueryParamString, bool isSandbox, CancellationToken ct)
{
try
{
string host = isSandbox ? "sandbox.payfast.co.za" : "www.payfast.co.za";
string targetUrl = $"https://{host}/eng/query/validate";
using var content = new StringContent(rawQueryParamString, Encoding.UTF8, "application/x-www-form-urlencoded");
var httpClient = httpClientFactory.CreateClient();
var response = await httpClient.PostAsync(targetUrl, content, ct);
if (!response.IsSuccessStatusCode) return Result.Fail<bool>("Failed to validate server confirmation.");
string responseText = await response.Content.ReadAsStringAsync(ct);
bool isValidated = string.Equals(responseText.Trim(), "VALID", StringComparison.OrdinalIgnoreCase);
if (!isValidated)
logger.LogWarning("SECURITY WARNING: Payfast back-channel returned validation response: '{Response}'", responseText);
return Result.Ok(isValidated);
}
catch (Exception ex)
{
return Result.Fail<bool>(new Error("Failed to complete back-channel cURL verification handshakes with Payfast remote endpoints.").CausedBy(ex));
}
}
public static Result<string> GenerateSignature(IDictionary<string, string?> data, string? passPhrase = null)
{
var pfOutput = new StringBuilder();
var mandatorySequence = GetPayfastMandatoryFieldSequence();
foreach (string key in mandatorySequence)
{
if (data.TryGetValue(key, out string? rawValue) && !string.IsNullOrEmpty(rawValue))
{
string encodedVal = HttpUtility.UrlEncode(rawValue.Trim());
string val = PercentEncodingRegex.Replace(encodedVal, m => m.Value.ToUpperInvariant());
pfOutput.Append($"{key}={val}&");
}
}
var getString = pfOutput.Length > 0
? pfOutput.ToString()[..^1]
: string.Empty;
if (!string.IsNullOrWhiteSpace(passPhrase))
{
string encodedPassphrase = HttpUtility.UrlEncode(passPhrase.Trim());
string safePassphrase = PercentEncodingRegex.Replace(encodedPassphrase, m => m.Value.ToUpperInvariant());
getString += $"&passphrase={safePassphrase}";
}
return HashService.ToMd5Hash(getString);
}
private static string[] GetPayfastMandatoryFieldSequence() =>
[
"merchant_id",
"merchant_key",
"return_url",
"cancel_url",
"notify_url",
"name_first",
"name_last",
"email_address",
"cell_number",
"m_payment_id",
"amount",
"item_name",
"item_description",
"custom_int1",
"custom_int2",
"custom_int3",
"custom_int4",
"custom_int5",
"custom_str1",
"custom_str2",
"custom_str3",
"custom_str4",
"custom_str5",
"email_confirmation",
"confirmation_address",
"payment_method",
"subscription_type",
"billing_date",
"recurring_amount",
"frequency",
"cycles"
];
}
@@ -116,16 +116,14 @@ public sealed class PaymentService(IDbContextFactory<MidrandBooksDbContext> cont
} }
} }
public async ValueTask<Result<bool>> HasLedgerEntryAsync(long orderId, long paymentId, long gatewayId, CancellationToken cancellationToken = default) public async ValueTask<Result<bool>> HasLedgerEntryAsync(long orderId, long paymentId, CancellationToken cancellationToken = default)
{ {
try try
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var exists = await context.Ledger.AnyAsync(l => var exists = await context.Ledger.AnyAsync(l =>
l.OrderId == orderId && l.OrderId == orderId && l.PaymentId == paymentId && l.Status == LedgerStatuses.Completed, cancellationToken);
l.PaymentId == paymentId &&
l.PaymentGatewayId == gatewayId, cancellationToken);
return Result.Ok(exists); return Result.Ok(exists);
} }
@@ -162,10 +160,9 @@ public sealed class PaymentService(IDbContextFactory<MidrandBooksDbContext> cont
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CustomerId = request.CustomerId, CustomerId = request.CustomerId,
OrderId = request.OrderId, OrderId = request.OrderId,
PaymentGatewayId = request.PaymentGatewayId,
PaymentGatewayReference = request.PaymentGatewayReference,
PaymentId = request.PaymentId, PaymentId = request.PaymentId,
Status = request.Status, MerchantPaymentId = request.PaymentGatewayReference,
Status = request.Status,
}); });
return await context.SaveChangesAsync(cancellationToken) > 0 return await context.SaveChangesAsync(cancellationToken) > 0
@@ -48,4 +48,6 @@ public sealed class MidrandBooksDbContext(DbContextOptions<MidrandBooksDbContext
public DbSet<PaymentGateway> Gateways => Set<PaymentGateway>(); public DbSet<PaymentGateway> Gateways => Set<PaymentGateway>();
public DbSet<PaymentLedger> Ledger => Set<PaymentLedger>(); public DbSet<PaymentLedger> Ledger => Set<PaymentLedger>();
public DbSet<PaymentGatewayLedger> GatewayLedger => Set<PaymentGatewayLedger>();
} }
@@ -7,7 +7,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class Init : Migration public sealed partial class Init : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -6,7 +6,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddedCategories : Migration public sealed partial class AddedCategories : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -6,7 +6,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddedProductCategories : Migration public sealed partial class AddedProductCategories : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -7,7 +7,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddedPaymentObjects : Migration public sealed partial class AddedPaymentObjects : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -5,7 +5,7 @@
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class RemovedPassphraseFromPaymentGateway : Migration public sealed partial class RemovedPassphraseFromPaymentGateway : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
@@ -0,0 +1,108 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public sealed partial class AddedPaymentGatewayLedger : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Ledger_Gateways_PaymentGatewayId",
table: "Ledger");
migrationBuilder.DropIndex(
name: "IX_Ledger_PaymentGatewayId",
table: "Ledger");
migrationBuilder.DropColumn(
name: "PaymentGatewayId",
table: "Ledger");
migrationBuilder.RenameColumn(
name: "PaymentGatewayReference",
table: "Ledger",
newName: "MerchantPaymentId");
migrationBuilder.CreateTable(
name: "GatewayLedger",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
CustomerEmail = table.Column<string>(type: "text", nullable: true),
OrderId = table.Column<long>(type: "bigint", nullable: false),
PaymentId = table.Column<long>(type: "bigint", nullable: false),
MerchantPaymentId = table.Column<string>(type: "text", nullable: true),
PayfastPaymentId = table.Column<string>(type: "text", nullable: false),
PaymentStatus = table.Column<string>(type: "text", nullable: true),
AmountGross = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
AmountFee = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
AmountNet = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GatewayLedger", x => x.Id);
table.ForeignKey(
name: "FK_GatewayLedger_Orders_OrderId",
column: x => x.OrderId,
principalTable: "Orders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GatewayLedger_Payments_PaymentId",
column: x => x.PaymentId,
principalTable: "Payments",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_GatewayLedger_OrderId",
table: "GatewayLedger",
column: "OrderId");
migrationBuilder.CreateIndex(
name: "IX_GatewayLedger_PaymentId",
table: "GatewayLedger",
column: "PaymentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GatewayLedger");
migrationBuilder.RenameColumn(
name: "MerchantPaymentId",
table: "Ledger",
newName: "PaymentGatewayReference");
migrationBuilder.AddColumn<long>(
name: "PaymentGatewayId",
table: "Ledger",
type: "bigint",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Ledger_PaymentGatewayId",
table: "Ledger",
column: "PaymentGatewayId");
migrationBuilder.AddForeignKey(
name: "FK_Ledger_Gateways_PaymentGatewayId",
table: "Ledger",
column: "PaymentGatewayId",
principalTable: "Gateways",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public sealed partial class AddedPayfastPaymentIdToPaymentGatewayLedger : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "MerchantPaymentId",
table: "GatewayLedger",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "MerchantPaymentId",
table: "GatewayLedger",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{
/// <inheritdoc />
public sealed partial class OnlyEmailIsMandatoryOnCustomer : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Website",
table: "Customers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "Phone",
table: "Customers",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Website",
table: "Customers",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "Phone",
table: "Customers",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
}
}
}
@@ -17,7 +17,7 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.8") .HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -309,7 +309,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasDefaultValue(true); .HasDefaultValue(true);
b.Property<string>("Phone") b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
@@ -321,7 +320,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Website") b.Property<string>("Website")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
@@ -615,6 +613,60 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.ToTable("Gateways", (string)null); b.ToTable("Gateways", (string)null);
}); });
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<decimal>("AmountFee")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<decimal>("AmountGross")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<decimal>("AmountNet")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("CustomerEmail")
.HasColumnType("text");
b.Property<string>("MerchantPaymentId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("OrderId")
.HasColumnType("bigint");
b.Property<string>("PayfastPaymentId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("PaymentId")
.HasColumnType("bigint");
b.Property<string>("PaymentStatus")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("OrderId");
b.HasIndex("PaymentId");
b.ToTable("GatewayLedger", (string)null);
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b => modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -631,15 +683,12 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Property<long>("CustomerId") b.Property<long>("CustomerId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<string>("MerchantPaymentId")
.HasColumnType("text");
b.Property<long>("OrderId") b.Property<long>("OrderId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<long?>("PaymentGatewayId")
.HasColumnType("bigint");
b.Property<string>("PaymentGatewayReference")
.HasColumnType("text");
b.Property<long>("PaymentId") b.Property<long>("PaymentId")
.HasColumnType("bigint"); .HasColumnType("bigint");
@@ -652,8 +701,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.HasIndex("OrderId"); b.HasIndex("OrderId");
b.HasIndex("PaymentGatewayId");
b.HasIndex("PaymentId"); b.HasIndex("PaymentId");
b.ToTable("Ledger", (string)null); b.ToTable("Ledger", (string)null);
@@ -1062,6 +1109,25 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Order"); b.Navigation("Order");
}); });
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGatewayLedger", b =>
{
b.HasOne("LiteCharms.Features.MidrandBooks.Orders.Entities.Order", "Order")
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment")
.WithMany()
.HasForeignKey("PaymentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Order");
b.Navigation("Payment");
});
modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b => modelBuilder.Entity("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentLedger", b =>
{ {
b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer") b.HasOne("LiteCharms.Features.MidrandBooks.Customers.Entities.Customer", "Customer")
@@ -1076,11 +1142,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.PaymentGateway", "Gateway")
.WithMany()
.HasForeignKey("PaymentGatewayId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment") b.HasOne("LiteCharms.Features.MidrandBooks.Payments.Entities.Payment", "Payment")
.WithMany() .WithMany()
.HasForeignKey("PaymentId") .HasForeignKey("PaymentId")
@@ -1089,8 +1150,6 @@ namespace LiteCharms.Features.MidrandBooks.Postgres.Migrations
b.Navigation("Customer"); b.Navigation("Customer");
b.Navigation("Gateway");
b.Navigation("Order"); b.Navigation("Order");
b.Navigation("Payment"); b.Navigation("Payment");
@@ -17,7 +17,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -36,7 +36,7 @@
<!-- Quartz Scheduler--> <!-- Quartz Scheduler-->
<ItemGroup> <ItemGroup>
<PackageReference Include="OpenTelemetry" Version="1.15.3" /> <PackageReference Include="OpenTelemetry" Version="1.16.0" />
<PackageReference Include="Quartz" Version="3.18.1" /> <PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" /> <PackageReference Include="Quartz.Plugins" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" /> <PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
@@ -50,11 +50,11 @@
<!-- Configuration --> <!-- Configuration -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" /> <Using Include="Microsoft.Extensions.Configuration" />
@@ -67,9 +67,9 @@
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" /> <Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
@@ -78,12 +78,12 @@
<!-- Open Telemetry --> <!-- Open Telemetry -->
<ItemGroup> <ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.16.0" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" /> <Using Include="OpenTelemetry.Resources" />
@@ -95,17 +95,17 @@
<!-- Database --> <!-- Database -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Npgsql" /> <Using Include="Npgsql" />
@@ -116,8 +116,8 @@
<!-- Email --> <!-- Email -->
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" /> <PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="MimeKit" Version="4.16.0" /> <PackageReference Include="MimeKit" Version="4.17.0" />
<!-- Global Usings--> <!-- Global Usings-->
<Using Include="MimeKit" /> <Using Include="MimeKit" />
@@ -136,8 +136,8 @@
<!-- Amazon S3 SDK --> <!-- Amazon S3 SDK -->
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.1" /> <PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.7" />
<PackageReference Include="AWSSDK.S3" Version="4.0.23.4" /> <PackageReference Include="AWSSDK.S3" Version="4.0.24.4" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" /> <ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
<!-- global Usings --> <!-- global Usings -->
@@ -1,7 +1,7 @@
using LiteCharms.Features.Extensions; using LiteCharms.Features.Extensions;
using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Extensions;
namespace LiteCharms.Features.MidrandBooks.Tests.Common; namespace LiteCharms.Features.Tests.Common;
public class Fixture : IDisposable public class Fixture : IDisposable
{ {
@@ -24,16 +24,20 @@ public class Fixture : IDisposable
.AddEnvironmentVariables() .AddEnvironmentVariables()
.Build(); .Build();
Services = new ServiceCollection() Services = new ServiceCollection()
.AddMediator()
.AddLogging() .AddLogging()
.AddMediator()
.AddEmailServiceBus() .AddEmailServiceBus()
.AddGarageS3(Configuration) .AddGarageS3(Configuration)
.AddMidrandShopDatabase(Configuration) .AddMidrandShopDatabase(Configuration)
.AddEmailServices(Configuration) .AddEmailServices(Configuration)
.AddSingleton(Configuration) .AddSingleton(Configuration)
.AddShopServices() .AddShopServices()
.BuildServiceProvider(); .AddHashServices(Configuration)
.AddLiteCharmsApiSecurity(Configuration)
.AddSecurityApiSdk(Configuration)
.AddPayfastServices(Configuration)
.BuildServiceProvider(); ;
Mediator = Services.GetRequiredService<IMediator>(); Mediator = Services.GetRequiredService<IMediator>();
} }
@@ -1,4 +1,4 @@
namespace LiteCharms.Features.MidrandBooks.Tests.Common; namespace LiteCharms.Features.Tests.Common;
public class IntegrationFactAttribute : FactAttribute public class IntegrationFactAttribute : FactAttribute
{ {
@@ -0,0 +1,75 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>0521f45a-eba0-457f-bb5e-c3680f65d8b1</UserSecretsId>
</PropertyGroup>
<!-- xUnit -->
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<Using Include="Xunit.Abstractions" />
</ItemGroup>
<!-- Mediator -->
<ItemGroup>
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<Using Include="Mediator" />
</ItemGroup>
<!-- Microsoft -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />
<Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="Microsoft.Extensions.Configuration" />
</ItemGroup>
<!-- Shared Usings -->
<ItemGroup>
<Using Include="System.Net" />
<Using Include="System.Text.Json" />
<Using Include="System.Diagnostics" />
<Using Include="System.Reflection" />
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LiteCharms.Features.MidrandBooks\LiteCharms.Features.MidrandBooks.csproj" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,16 +1,32 @@
{ {
"PayfastSettings": {
"CheckoutUrl": "https://sandbox.payfast.co.za/eng/process",
"ValidHosts": [
"www.payfast.co.za",
"sandbox.payfast.co.za",
"ips.payfast.co.za",
"api.payfast.co.za",
"payment.payfast.io"
]
},
"LiteCharmsSettings": {
"Authority": "https://sts.security.khongisa.co.za",
"Audience": "midrandbooks-api"
},
"LiteCharmsClientSettings": {
"Authority": "https://sts.security.khongisa.co.za",
"GrantType": "client_credentials",
"Scope": "midrandbooks-api"
},
"HasherSettings": {
"MinHashLength": 11
},
"BookshopS3Settings": { "BookshopS3Settings": {
"ServiceUrl": "http://192.168.1.177:30900", "ServiceUrl": "http://192.168.1.177:30900",
"Region": "garage", "Region": "garage",
"BucketName": "bookshop", "BucketName": "bookshop",
"CdnBaseUrl": "https://bookshop.cdn.khongisa.co.za" "CdnBaseUrl": "https://bookshop.cdn.khongisa.co.za"
}, },
"BookshopQuotesS3Settings": {
"ServiceUrl": "http://192.168.1.177:30900",
"Region": "garage",
"BucketName": "bookshop.quotes",
"CdnBaseUrl": "https://bookshop.quotes.cdn.khongisa.co.za"
},
"Email": { "Email": {
"Credentials": { "Credentials": {
"Username": "shop@litecharms.co.za" "Username": "shop@litecharms.co.za"
@@ -20,7 +36,6 @@
"UseSsl": true "UseSsl": true
}, },
"Monitoring": { "Monitoring": {
"ApiKey": "",
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889", "Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
"ServiceName": "LiteCharms.LeadGenerator" "ServiceName": "LiteCharms.LeadGenerator"
}, },
-36
View File
@@ -1,36 +0,0 @@
using LiteCharms.Features.Extensions;
namespace LiteCharms.Features.Tests;
public class Fixture : IDisposable
{
public IConfiguration Configuration { get; set; }
public IServiceProvider Services { get; set; }
public IMediator Mediator { get; set; }
public Fixture()
{
Configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddUserSecrets<Fixture>()
.AddJsonFile(Path.Combine(Directory.GetCurrentDirectory(), "appsettings.json"), optional: true, reloadOnChange: true)
.AddEnvironmentVariables()
.Build();
Services = new ServiceCollection()
.AddMediator()
.AddLogging()
.AddEmailServiceBus()
.AddGarageS3(Configuration)
.AddEmailServices(Configuration)
.AddSingleton(Configuration)
.AddHashServices(Configuration)
.BuildServiceProvider();
Mediator = Services.GetRequiredService<IMediator>();
}
public void Dispose() { }
}
@@ -1,13 +1,11 @@
using LiteCharms.Features.Hasher; using LiteCharms.Features.Hasher;
using LiteCharms.Features.Models; using LiteCharms.Features.Tests.Common;
using static LiteCharms.Features.Extensions.Hash;
namespace LiteCharms.Features.Tests; namespace LiteCharms.Features.Tests;
public class HashServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture> public class HashServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{ {
private readonly HashService hashService = fixture.Services.GetRequiredService<HashService>(); private readonly HashService hashService = fixture.Services.GetRequiredService<HashService>();
private readonly string payfastPassphrase = fixture.Configuration.GetSection("HasherSettings:PayfastPassphrase").Value!;
[Fact] [Fact]
public void StringToSha256Hash_Should_GenerateHash() public void StringToSha256Hash_Should_GenerateHash()
@@ -62,28 +60,6 @@ public class HashServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
Assert.Equal(expectedMd5Lowercase, result.Value); Assert.Equal(expectedMd5Lowercase, result.Value);
} }
[Fact]
public void VerifyPayfastWebhookSignature_Should_GenerateHash()
{
var paymentId = hashService.HashEncodeLongId(1001).Value;
var payload = new PayfastWebhookPayload
{
Amount = "350.00",
ItemName = "System Architecture Book",
MPaymentId = paymentId,
};
var rawPayload = payload.ToRawPayfastPayload(payfastPassphrase);
var generatedSignature = HashService.ToMd5Hash(rawPayload).Value;
var result = hashService.VerifyPayfastWebhookSignature(payload, generatedSignature);
Assert.True(result.IsSuccess);
Assert.True(result.Value);
}
[Fact] [Fact]
public void HashEncodeHex_Should_GenerateHash() public void HashEncodeHex_Should_GenerateHash()
{ {
@@ -12,12 +12,8 @@
<PackageReference Include="coverlet.collector" Version="10.0.1"> <PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Mediator.SourceGenerator" Version="3.0.2"> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -31,11 +27,13 @@
<Using Include="System.Text" /> <Using Include="System.Text" />
<Using Include="Mediator" /> <Using Include="Mediator" />
<Using Include="Xunit.Abstractions" /> <Using Include="Xunit.Abstractions" />
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Microsoft.Extensions.DependencyInjection" /> <Using Include="Microsoft.Extensions.DependencyInjection" />
<Using Include="Microsoft.Extensions.Configuration" /> <Using Include="Microsoft.Extensions.Configuration" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\LiteCharms.Features.Tests.Common\LiteCharms.Features.Tests.Common.csproj" />
<ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" /> <ProjectReference Include="..\LiteCharms.Features\LiteCharms.Features.csproj" />
</ItemGroup> </ItemGroup>
@@ -43,10 +41,4 @@
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>
@@ -0,0 +1,19 @@
using LiteCharms.Features.Api;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.Tests;
public sealed class LiteCharmsApiFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly TokenService tokenService = fixture.Services.GetRequiredService<TokenService>();
[IntegrationFact]
public async Task TokenService_GenerateTokenAsync_ShouldReturn_TokenInResult()
{
var result = await tokenService.GenerateAsync(fixture.CancellationToken);
Assert.True(result.IsSuccess);
Assert.NotNull(result.Value);
Assert.NotEmpty(result.Value.AccessToken!);
}
}
@@ -0,0 +1,18 @@
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.Tests;
public sealed class PayfastFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly PayfastSettings payfastSettings = fixture.Services.GetRequiredService<IOptions<PayfastSettings>>().Value;
[IntegrationFact]
public void PayfastSettings_ShouldFail_IfNotLoaded()
{
Assert.NotEmpty(payfastSettings.CheckoutUrl!);
Assert.NotEmpty(payfastSettings.MerchantId!);
Assert.NotEmpty(payfastSettings.MerchantKey!);
Assert.NotEmpty(payfastSettings.Passphrase!);
}
}
@@ -1,4 +1,5 @@
using LiteCharms.Features.S3.Abstractions; using LiteCharms.Features.S3.Abstractions;
using LiteCharms.Features.Tests.Common;
namespace LiteCharms.Features.Tests; namespace LiteCharms.Features.Tests;
@@ -0,0 +1,6 @@
### Authentik Token Request (Service Account Explicit)
POST {{authority}}/connect/token
Content-Type: application/x-www-form-urlencoded
Accept-Encoding: identity
grant_type={{grantType}}&client_id={{clientId}}&client_secret={{clientSecret}}&scope={{scope}}
@@ -0,0 +1,9 @@
{
"uat": {
"authority": "https://sts.security.khongisa.co.za",
"grantType": "client_credentials",
"clientId": "midrandbooks-api-scaler-uat",
"clientSecret": "secret_0a8dc1f99061590a52b1272db3a1871d2761c79fbd058b2a968911029e4b208a",
"scope": "midrandbooks-api"
}
}
@@ -0,0 +1,8 @@
## Payfast Payment Confirmation
# This endpoint is used by Payfast to confirm the payment status of a transaction.
# It receives a POST request with the payment details and updates the order status accordingly.
POST {{baseUrl}}/v1/payments/payfast/confirm
Content-Type: application/x-www-form-urlencoded
amount={{amount}}&item_name={{item_name}}&m_payment_id={{paymentId}}&signature={{signature}}
@@ -0,0 +1,17 @@
{
"payfast-local": {
"baseUrl": "https://localhost:7196",
"paymentId": "jdPB2zaKM3Z",
"signature": "6aeff59bb74f2448ff2c3d81b2ec95de",
"item_name": "System Architecture Book",
"amount": "350.00"
},
"payfast-uat": {
"baseUrl": "https://api.uat.midrandbooks.co.za",
"paymentId": "jdPB2zaKM3Z",
"signature": "6aeff59bb74f2448ff2c3d81b2ec95de",
"item_name": "System Architecture Book",
"amount": "350.00"
}
}
@@ -0,0 +1,6 @@
namespace LiteCharms.Features.Abstractions;
public interface IEndpoint
{
void Map(IEndpointRouteBuilder builder);
}
@@ -0,0 +1,3 @@
namespace LiteCharms.Features.Abstractions;
public interface IFeatures;
@@ -0,0 +1,12 @@
namespace LiteCharms.Features.Abstractions;
public interface IJobOrchestrator
{
ValueTask SendAsync<TNotification>(TNotification notification, CancellationToken cancellationToken = default)
where TNotification : IEvent;
ValueTask ScheduleAsync<TNotification>(TNotification notification, string cronExpression, CancellationToken cancellationToken = default)
where TNotification : IEvent;
ValueTask<bool> InterruptAsync(string eventName, string? correlationId = null, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,7 @@
namespace LiteCharms.Features.Api;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class ApiVersionTargetAttribute(int majorVersion) : Attribute
{
public int MajorVersion { get; } = majorVersion;
}
@@ -0,0 +1,14 @@
namespace LiteCharms.Features.Api.Configuration;
public sealed class LiteCharmsClientSettings
{
public string? Authority { get; set; }
public string? GrantType { get; set; }
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? Scope { get; set; }
}
@@ -0,0 +1,12 @@
namespace LiteCharms.Features.Api.Configuration;
public sealed class LiteCharmsSettings
{
public string? Authority { get; set; }
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? Audience { get; set; }
}
@@ -0,0 +1,14 @@
namespace LiteCharms.Features.Api.Configuration;
public sealed class PayfastSettings
{
public string? CheckoutUrl { get; set; }
public string? Passphrase { get; set; }
public string? MerchantId { get; set; }
public string? MerchantKey { get; set; }
public string[]? ValidHosts { get; set; }
}
@@ -0,0 +1,13 @@
namespace LiteCharms.Features.Api.Models;
public sealed class TokenErrorResponse
{
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; set; }
[JsonPropertyName("error_uri")]
public string? ErrorUri { get; set; }
}
@@ -0,0 +1,20 @@
namespace LiteCharms.Features.Api.Models;
public sealed class TokenRequest
{
[JsonPropertyName("grant_type")]
[AliasAs("grant_type")]
public string? GrantType { get; set; }
[JsonPropertyName("client_id")]
[AliasAs("client_id")]
public string? ClientId { get; set; }
[JsonPropertyName("client_secret")]
[AliasAs("client_secret")]
public string? ClientSecret { get; set; }
[JsonPropertyName("scope")]
[AliasAs("scope")]
public string? Scope { get; set; }
}
@@ -0,0 +1,17 @@
namespace LiteCharms.Features.Api.Models;
public sealed class TokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("token_type")]
public string? TokenType { get; set; }
[JsonPropertyName("scope")]
public string? Scope { get; set; }
}
@@ -0,0 +1,16 @@
namespace LiteCharms.Features.Api;
public sealed class OpenApiBearerSecuritySchemeTransformer : IOpenApiDocumentTransformer
{
public async Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken)
{
var bearerScheme = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
Description = "JWT Authorization header using the Bearer scheme",
};
document.AddComponent("Bearer", bearerScheme);
}
}
@@ -0,0 +1,10 @@
using LiteCharms.Features.Api.Models;
namespace LiteCharms.Features.Api.Sdk;
public interface IConnectApi
{
[Post("/connect/token")]
ValueTask<HttpResponseMessage> GetToken([Body(BodySerializationMethod.UrlEncoded)] TokenRequest request,
CancellationToken cancellationToken = default);
}
+66
View File
@@ -0,0 +1,66 @@
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Api.Models;
using LiteCharms.Features.Api.Sdk;
namespace LiteCharms.Features.Api;
public sealed class TokenService(IConnectApi connectApi, IOptions<LiteCharmsClientSettings> clientOptions)
{
private readonly LiteCharmsClientSettings clientSettings = clientOptions.Value;
public async Task<Result<TokenResponse>> GenerateAsync(CancellationToken cancellationToken = default)
{
try
{
var request = new TokenRequest
{
ClientId = clientSettings.ClientId,
ClientSecret = clientSettings.ClientSecret,
GrantType = clientSettings.GrantType,
Scope = clientSettings.Scope,
};
using var response = await connectApi.GetToken(request, cancellationToken);
var contentRaw = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(contentRaw))
return Result.Fail(new Error($"The authentication endpoint returned an empty payload. Status code: {response.StatusCode}"));
if (response.IsSuccessStatusCode)
{
var tokenResponse = JsonSerializer.Deserialize<TokenResponse>(contentRaw);
return !string.IsNullOrWhiteSpace(tokenResponse?.AccessToken)
? Result.Ok(tokenResponse)
: Result.Fail<TokenResponse>(new Error("Authentication succeeded, but no access token was found in the response payload."));
}
try
{
var errorResult = JsonSerializer.Deserialize<TokenErrorResponse>(contentRaw);
if (errorResult != null)
{
string summary = $"{errorResult.Error}: {errorResult.ErrorDescription}";
return Result.Fail(new Error(summary));
}
}
catch
{
return Result.Fail(new Error($"Authentication failed: {contentRaw}"));
}
return Result.Fail(new Error($"Authentication failed with status code: {response.StatusCode}"));
}
catch (OperationCanceledException ex)
{
return Result.Fail(new Error("The token generation request was canceled.").CausedBy(ex));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -0,0 +1,78 @@
namespace LiteCharms.Features.Browser;
public sealed class LocalStorageService(ProtectedLocalStorage storage)
{
public async ValueTask<Result> DeleteAsync(string key)
{
try
{
await storage.DeleteAsync(key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> SaveAsync(string key, string value)
{
try
{
await storage.SetAsync(key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> SaveAsync<TValue>(string key, TValue value) where TValue : class
{
try
{
await storage.SetAsync(key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<string>> GetAsync(string key)
{
try
{
var retrieval = await storage.GetAsync<string>(key);
return retrieval.Success && !string.IsNullOrWhiteSpace(retrieval.Value)
? Result.Ok(retrieval.Value)
: Result.Fail($"Could not find object by key {key}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<TValue>> GetAsync<TValue>(string key) where TValue : class
{
try
{
var retrieval = await storage.GetAsync<TValue>(key);
return retrieval.Success && retrieval.Value is not null
? Result.Ok(retrieval.Value)
: Result.Fail($"Could not find object by key {key}");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -0,0 +1,16 @@
namespace LiteCharms.Features;
public sealed class CancellationTokenProvider : IDisposable
{
private readonly CancellationTokenSource source = new();
public CancellationToken Token => source.Token;
public void Dispose()
{
source.Cancel();
source.Dispose();
GC.SuppressFinalize(this);
}
}
@@ -7,25 +7,25 @@ public sealed class EmailEnquiryModel
[Required] [Required]
[MinLength(2)] [MinLength(2)]
[MaxLength(255)] [MaxLength(255)]
[Display(Name = "Full Name")] [System.ComponentModel.DataAnnotations.Display(Name = "Full Name")]
public string? FullName { get; set; } public string? FullName { get; set; }
[Required] [Required]
[EmailAddress] [EmailAddress]
[MinLength(5)] [MinLength(5)]
[MaxLength(255)] [MaxLength(255)]
[Display(Name = "Email Address")] [System.ComponentModel.DataAnnotations.Display(Name = "Email Address")]
public string? EmailAddress { get; set; } public string? EmailAddress { get; set; }
[Required] [Required]
[MinLength(2)] [MinLength(2)]
[MaxLength(255)] [MaxLength(255)]
[Display(Name = "Subject")] [System.ComponentModel.DataAnnotations.Display(Name = "Subject")]
public string? EmailSubject { get; set; } public string? EmailSubject { get; set; }
[Required] [Required]
[MinLength(2)] [MinLength(2)]
[MaxLength(2000)] [MaxLength(2000)]
[Display(Name = "Message")] [System.ComponentModel.DataAnnotations.Display(Name = "Message")]
public string? Message { get; set; } public string? Message { get; set; }
} }
+1
View File
@@ -19,6 +19,7 @@ public enum LedgerStatuses : int
Cancelled = 4, Cancelled = 4,
Failed = 5, Failed = 5,
Partial = 6, Partial = 6,
Completed = 7,
} }
public enum PaymentStatuses : int public enum PaymentStatuses : int
+306
View File
@@ -0,0 +1,306 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Api;
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Api.Sdk;
using LiteCharms.Features.Postgres;
namespace LiteCharms.Features.Extensions;
public static class Api
{
public const string Books = nameof(Books);
public const string Payments = nameof(Payments);
public static IServiceCollection AddPayfastServices(this IServiceCollection services, IConfiguration configuration)
{
var configSection = configuration.GetSection(nameof(PayfastSettings));
services.Configure<PayfastSettings>(configSection);
return services;
}
public static IServiceCollection AddSecurityApiSdk(this IServiceCollection services, IConfiguration configuration)
{
var configSection = configuration.GetSection(nameof(LiteCharmsClientSettings));
var authOptions = new LiteCharmsClientSettings();
configSection.Bind(authOptions);
services.Configure<LiteCharmsClientSettings>(configSection);
if (string.IsNullOrWhiteSpace(authOptions.Authority))
return services;
if (!authOptions.Authority.EndsWith("/", StringComparison.Ordinal)) authOptions.Authority += "/";
services.AddRefitClient<IConnectApi>()
.ConfigureHttpClient(config =>
{
config.BaseAddress = new Uri(authOptions.Authority);
config.Timeout = TimeSpan.FromSeconds(15);
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.Delay = TimeSpan.FromSeconds(1);
options.Retry.BackoffType = Polly.DelayBackoffType.Exponential;
});
services.AddScoped<TokenService>();
return services;
}
public static IServiceCollection AddLiteCharmsWebSecurity(this IServiceCollection services, IConfiguration configuration)
{
var certString = configuration["DataProtection:Certificate"] ?? configuration["DataProtection__Certificate"];
var certPassword = configuration["DataProtection:Password"] ?? configuration["DataProtection__Password"];
if (string.IsNullOrEmpty(certString))
throw new InvalidOperationException("Data Protection Certificate configuration is missing.");
var certificate = X509CertificateLoader.LoadPkcs12(Convert.FromBase64String(certString), certPassword);
services.AddDataProtection().PersistKeysToDbContext<DataProtectionDbContext>()
.ProtectKeysWithCertificate(certificate)
.SetApplicationName("LiteCharmsApp");
services.Configure<DataProtectionOptions>(options => options.ApplicationDiscriminator = "LiteCharmsApp");
services.ConfigureCookieOidcSameSiteSupport();
var configSection = configuration.GetSection(nameof(LiteCharmsSettings));
var authOptions = new LiteCharmsSettings();
configSection.Bind(authOptions);
services.Configure<LiteCharmsSettings>(configSection);
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.Name = "LiteCharmsApp.Session";
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.Authority = authOptions.Authority;
options.ClientId = authOptions.ClientId;
options.ClientSecret = authOptions.ClientSecret;
options.ResponseType = "code";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
options.CorrelationCookie.SameSite = SameSiteMode.None;
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
options.NonceCookie.SameSite = SameSiteMode.None;
options.ForwardSignOut = CookieAuthenticationDefaults.AuthenticationScheme;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProviderForSignOut = context =>
{
var idToken = context.ProtocolMessage.IdTokenHint;
if (string.IsNullOrEmpty(idToken))
{
var tokens = context.Properties.GetTokens();
var idTokenItem = tokens.FirstOrDefault(t => string.Equals(t.Name, "id_token", StringComparison.Ordinal));
if (idTokenItem != null) context.ProtocolMessage.IdTokenHint = idTokenItem.Value;
}
return Task.CompletedTask;
},
};
});
services.AddCascadingAuthenticationState();
return services;
}
private static void ConfigureCookieOidcSameSiteSupport(this IServiceCollection services) =>
services.Configure<CookiePolicyOptions>(options =>
{
options.MinimumSameSitePolicy = SameSiteMode.Unspecified;
options.OnAppendCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
options.OnDeleteCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
});
private static void CheckSameSite(HttpContext httpContext, CookieOptions options)
{
if (options.SameSite == SameSiteMode.None)
{
bool isSecure = httpContext.Request.IsHttps;
if (!isSecure && httpContext.Request.Headers.TryGetValue("X-Forwarded-Proto", out var proto))
isSecure = string.Equals(proto, "https", StringComparison.OrdinalIgnoreCase);
if (!isSecure && httpContext.Request.Headers.TryGetValue("Forwarded", out var forwarded))
isSecure = forwarded.ToString().Contains("proto=https", StringComparison.OrdinalIgnoreCase);
if (!isSecure) options.SameSite = SameSiteMode.Unspecified;
}
}
public static IServiceCollection AddLiteCharmsApiSecurity(this IServiceCollection services, IConfiguration configuration)
{
var configSection = configuration.GetSection(nameof(LiteCharmsSettings));
var authOptions = new LiteCharmsSettings();
configSection.Bind(authOptions);
services.Configure<LiteCharmsSettings>(configSection);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = authOptions.Authority;
options.Audience = authOptions.Audience;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = authOptions.Authority,
ValidateAudience = true,
ValidateIssuer = true,
};
});
services.AddAuthorization();
return services;
}
public static WebApplication AddSecurityEndpoints(this WebApplication app)
{
app.MapGet("/login", async (HttpContext context, string redirectUri = "/") =>
{
await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties
{
RedirectUri = redirectUri,
});
});
app.MapGet("/logout", async (HttpContext context, string? redirectUri = null) =>
{
var idToken = await context.GetTokenAsync("id_token");
if (string.IsNullOrWhiteSpace(redirectUri))
{
var host = context.Request.Host.ToUriComponent();
redirectUri = $"https://{host}/";
}
var authProperties = new AuthenticationProperties { RedirectUri = redirectUri, };
if (!string.IsNullOrEmpty(idToken))
authProperties.Parameters.Add("id_token_hint", idToken);
await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
});
return app;
}
public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient();
services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1);
options.ReportApiVersions = true;
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = ApiVersionReader.Combine(new UrlSegmentApiVersionReader(),
new QueryStringApiVersionReader("version"),
new QueryStringApiVersionReader("version"),
new MediaTypeApiVersionReader("version"));
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
var urls = configuration["ASPNETCORE_URLS"] ?? configuration["Urls"];
var healthUrl = "http://localhost:8080/health";
if (!string.IsNullOrWhiteSpace(urls))
{
string firstUrl = urls.Split(';').FirstOrDefault(s => s.Contains("http://", StringComparison.InvariantCultureIgnoreCase))!
.Replace("0.0.0.0", "localhost")
.Replace("*", "localhost")
.Replace("+", "localhost");
healthUrl = $"{firstUrl.TrimEnd('/')}/health";
}
services.AddHealthChecksUI(setup =>
{
setup.SetNotifyUnHealthyOneTimeUntilChange();
setup.AddHealthCheckEndpoint("primary, heal", healthUrl);
setup.SetHeaderText("Midrand Books");
})
.AddInMemoryStorage();
services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Cache());
options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(10);
});
services.AddOpenApi(options => options.AddDocumentTransformer<OpenApiBearerSecuritySchemeTransformer>());
return services;
}
public static IApplicationBuilder MapEndpoints(this WebApplication app, IDictionary<int, RouteGroupBuilder> versionGroups)
{
var endpoints = app.Services.GetRequiredService<IEnumerable<IEndpoint>>();
foreach (var endpoint in endpoints)
{
var versionAttributes = endpoint.GetType().GetCustomAttributes<ApiVersionTargetAttribute>().ToList();
if (versionAttributes.Count != 0)
{
foreach (var attr in versionAttributes)
if (versionGroups.TryGetValue(attr.MajorVersion, out var targetGroup))
endpoint.Map(targetGroup);
}
else
endpoint.Map(app);
}
return app;
}
public static IServiceCollection AddEndpoints(this IServiceCollection services, Assembly assembly)
{
ServiceDescriptor[] discriptors = [.. assembly.DefinedTypes
.Where(t => t is { IsInterface: false, IsAbstract: false })
.Where(t => t.IsAssignableTo(typeof(IEndpoint)))
.Select(t => ServiceDescriptor.Transient(typeof(IEndpoint), t))];
services.TryAddEnumerable(discriptors);
return services;
}
public static string ToEndpointName(this Type target, string? annotation = "") =>
$"{target.Name.Replace("Endpoint", string.Empty)}{annotation}".ToLower(CultureInfo.CurrentCulture);
}
+1 -80
View File
@@ -1,6 +1,5 @@
using LiteCharms.Features.Hasher; using LiteCharms.Features.Hasher;
using LiteCharms.Features.Hasher.Configuration; using LiteCharms.Features.Hasher.Configuration;
using LiteCharms.Features.Models;
namespace LiteCharms.Features.Extensions; namespace LiteCharms.Features.Extensions;
@@ -20,83 +19,5 @@ public static class Hash
services.AddSingleton<HashService>(); services.AddSingleton<HashService>();
return services; return services;
} }
public static string ToRawPayfastPayload(this PayfastWebhookPayload input, string passphrase)
{
var parameters = new List<string>();
if (!string.IsNullOrWhiteSpace(input.Amount))
parameters.Add($"amount={WebUtility.UrlEncode(input.Amount)}");
if (!string.IsNullOrWhiteSpace(input.ItemName))
parameters.Add($"item_name={WebUtility.UrlEncode(input.ItemName)}");
if (!string.IsNullOrWhiteSpace(input.MPaymentId))
parameters.Add($"m_payment_id={WebUtility.UrlEncode(input.MPaymentId)}");
string payload = string.Join("&", parameters);
if (!string.IsNullOrWhiteSpace(passphrase))
payload += $"&passphrase={WebUtility.UrlEncode(passphrase)}";
return payload;
}
public static (PayfastWebhookPayload Payload, string Passphrase) FromRawPayfastPayload(this string rawPayload)
{
string passphrase = string.Empty;
var payload = new PayfastWebhookPayload();
if (string.IsNullOrWhiteSpace(rawPayload)) return (payload, passphrase);
var segments = rawPayload.Split('&', StringSplitOptions.RemoveEmptyEntries);
foreach (var segment in segments)
{
int delimiterIndex = segment.IndexOf('=');
if (delimiterIndex == -1)
continue;
string key = segment[..delimiterIndex].Trim();
string rawValue = segment[(delimiterIndex + 1)..];
string decodedValue = WebUtility.UrlDecode(rawValue);
switch (key.ToLowerInvariant())
{
case "amount":
payload.Amount = decodedValue;
break;
case "item_name":
payload.ItemName = decodedValue;
break;
case "m_payment_id":
payload.MPaymentId = decodedValue;
break;
case "passphrase":
passphrase = decodedValue;
break;
}
}
return (payload, passphrase);
}
public static (PayfastWebhookPayload Payload, string Passphrase) FromRawPayfastPayload(this IFormCollection form)
{
string passphrase = string.Empty;
var payload = new PayfastWebhookPayload();
if (form.IsNullOrEmpty()) return (payload, passphrase);
payload = new PayfastWebhookPayload
{
Amount = form.TryGetValue("amount", out var amountValues) ? amountValues.ToString() : null,
ItemName = form.TryGetValue("item_name", out var itemValues) ? itemValues.ToString() : null,
MPaymentId = form.TryGetValue("m_payment_id", out var paymentIdValues) ? paymentIdValues.ToString() : null,
};
return (payload, passphrase);
}
} }
+15 -2
View File
@@ -1,6 +1,19 @@
namespace LiteCharms.Features.Extensions; using LiteCharms.Features.Postgres;
namespace LiteCharms.Features.Extensions;
public static class Postgres public static class Postgres
{ {
public const string SchedulerDbConfigName = "PostgresScheduler"; public const string SchedulerDbConfigName = "PostgresScheduler";
public const string DataProtectionDbConfigName = "PostgresDataProtection";
public static IServiceCollection AddDataProtectionDatabase(this IServiceCollection services, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DataProtectionDbConfigName);
services.AddPooledDbContextFactory<DataProtectionDbContext>(options =>
options.UseNpgsql(connectionString));
return services;
}
} }
+2 -2
View File
@@ -1,5 +1,5 @@
using LiteCharms.Features.Quartz; using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Quartz.Abstractions; using LiteCharms.Features.Quartz;
using static LiteCharms.Features.Extensions.Postgres; using static LiteCharms.Features.Extensions.Postgres;
namespace LiteCharms.Features.Extensions; namespace LiteCharms.Features.Extensions;
+3 -3
View File
@@ -19,7 +19,7 @@ public static class S3
AuthenticationRegion = configuration.GetSection($"{BookshopS3SettingsSection}:Region").Value, AuthenticationRegion = configuration.GetSection($"{BookshopS3SettingsSection}:Region").Value,
ForcePathStyle = true, ForcePathStyle = true,
EndpointDiscoveryEnabled = true, EndpointDiscoveryEnabled = true,
UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://") UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
})); }));
services.AddKeyedScoped<IS3Service, BookshopS3Service>(BookshopBucketName); services.AddKeyedScoped<IS3Service, BookshopS3Service>(BookshopBucketName);
@@ -36,7 +36,7 @@ public static class S3
AuthenticationRegion = configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:Region").Value, AuthenticationRegion = configuration.GetSection($"{BookshopInvoicesS3SettingsSection}:Region").Value,
ForcePathStyle = true, ForcePathStyle = true,
EndpointDiscoveryEnabled = true, EndpointDiscoveryEnabled = true,
UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://") UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
})); }));
services.AddKeyedScoped<IS3Service, BookshopInvoicesS3Service>(BookshopInvoicesBucketName); services.AddKeyedScoped<IS3Service, BookshopInvoicesS3Service>(BookshopInvoicesBucketName);
@@ -53,7 +53,7 @@ public static class S3
AuthenticationRegion = configuration.GetSection($"{BookshopQuotesS3SettingsSection}:Region").Value, AuthenticationRegion = configuration.GetSection($"{BookshopQuotesS3SettingsSection}:Region").Value,
ForcePathStyle = true, ForcePathStyle = true,
EndpointDiscoveryEnabled = true, EndpointDiscoveryEnabled = true,
UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://") UseHttp = configuration.GetSection($"{BookshopS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
})); }));
services.AddKeyedScoped<IS3Service, BookshopQuotesS3Service>(BookshopQuotesBucketName); services.AddKeyedScoped<IS3Service, BookshopQuotesS3Service>(BookshopQuotesBucketName);
@@ -0,0 +1,13 @@
namespace LiteCharms.Features.Extensions;
public static class TaskCancellation
{
public static IServiceCollection AddCancellationToken(this IServiceCollection services)
{
services.AddScoped<CancellationTokenProvider>();
services.AddScoped(typeof(CancellationToken),
provider => provider.GetRequiredService<CancellationTokenProvider>().Token);
return services;
}
}
+2 -44
View File
@@ -1,14 +1,10 @@
using LiteCharms.Features.Abstractions; using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Hasher.Configuration;
using LiteCharms.Features.Models;
namespace LiteCharms.Features.Hasher; namespace LiteCharms.Features.Hasher;
public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings> options) : IService public sealed partial class HashService(IHashids hasher) : IService
{ {
private readonly HasherSettings settings = options.Value; [GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)]
[GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z")]
private static partial Regex HexHashRegex { get; } private static partial Regex HexHashRegex { get; }
[GeneratedRegex(@"\A[0-9a-fA-F]{32}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] [GeneratedRegex(@"\A[0-9a-fA-F]{32}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)]
@@ -41,44 +37,6 @@ public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings
return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant()); return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant());
} }
public Result<bool> VerifyPayfastWebhookSignature(PayfastWebhookPayload payload, string incomingSignature)
{
try
{
if (string.IsNullOrWhiteSpace(incomingSignature))
return Result.Fail<bool>("Validation failed: Missing signature string parameter.");
var parameters = new List<string>();
if (!string.IsNullOrWhiteSpace(payload.Amount))
parameters.Add($"amount={WebUtility.UrlEncode(payload.Amount)}");
if (!string.IsNullOrWhiteSpace(payload.ItemName))
parameters.Add($"item_name={WebUtility.UrlEncode(payload.ItemName)}");
if (!string.IsNullOrWhiteSpace(payload.MPaymentId))
parameters.Add($"m_payment_id={WebUtility.UrlEncode(payload.MPaymentId)}");
string signatureString = string.Join("&", parameters);
if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase))
signatureString += $"&passphrase={WebUtility.UrlEncode(settings.PayfastPassphrase)}";
var localHashResult = ToMd5Hash(signatureString);
if (!localHashResult.IsSuccess)
return Result.Fail<bool>(localHashResult.Errors);
bool isValid = string.Equals(localHashResult.Value, incomingSignature, StringComparison.OrdinalIgnoreCase);
return Result.Ok(isValid);
}
catch (Exception ex)
{
return Result.Fail<bool>(new Error("An error occurred during Payfast MD5 verification.").CausedBy(ex));
}
}
public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input) public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input)
? Result.Fail<string>("Input must be a valid hexadecimal string.") ? Result.Fail<string>("Input must be a valid hexadecimal string.")
: Result.Ok(hasher.EncodeHex(input)); : Result.Ok(hasher.EncodeHex(input));
+79 -22
View File
@@ -29,14 +29,61 @@
<None Include="..\icon.png" Pack="true" PackagePath="\" /> <None Include="..\icon.png" Pack="true" PackagePath="\" />
</ItemGroup> </ItemGroup>
<!-- Security (IODC)-->
<ItemGroup>
<PackageReference Include="IdentityModel.AspNetCore" Version="4.3.0" />
<PackageReference Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.7.0" />
<PackageReference Include="Polly" Version="8.7.0" />
<PackageReference Include="Polly.Extensions" Version="8.7.0" />
<Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
<Using Include="IdentityModel.AspNetCore.OAuth2Introspection" />
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
</ItemGroup>
<!-- API SDK Composer-->
<ItemGroup>
<PackageReference Include="Refit.HttpClientFactory" Version="11.0.1" />
<Using Include="Refit" />
</ItemGroup>
<!-- API Versioning -->
<ItemGroup>
<PackageReference Include="AccessTokenClient.Extensions" Version="5.1.0" />
<PackageReference Include="Asp.Versioning.Abstractions" Version="10.0.0" />
<PackageReference Include="Asp.Versioning.Http" Version="10.0.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
<Using Include="Asp.Versioning" />
</ItemGroup>
<!-- API Documentation -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.3" />
<Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" />
<Using Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<!-- Quartz Scheduler--> <!-- Quartz Scheduler-->
<ItemGroup> <ItemGroup>
<PackageReference Include="Hashids.net" Version="1.7.0" /> <PackageReference Include="Hashids.net" Version="1.7.0" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.96"> <PackageReference Include="Meziantou.Analyzer" Version="3.0.104">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="OpenTelemetry" Version="1.15.3" /> <PackageReference Include="OpenTelemetry" Version="1.16.0" />
<PackageReference Include="Quartz" Version="3.18.1" /> <PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins" Version="3.18.1" /> <PackageReference Include="Quartz.Plugins" Version="3.18.1" />
<PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" /> <PackageReference Include="Quartz.Plugins.TimeZoneConverter" Version="3.18.1" />
@@ -50,11 +97,11 @@
<!-- Configuration --> <!-- Configuration -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Configuration" /> <Using Include="Microsoft.Extensions.Configuration" />
@@ -67,9 +114,9 @@
<PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Core" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.Data" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" /> <PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" /> <Using Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
@@ -78,12 +125,12 @@
<!-- Open Telemetry --> <!-- Open Telemetry -->
<ItemGroup> <ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.16.0" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="OpenTelemetry.Resources" /> <Using Include="OpenTelemetry.Resources" />
@@ -95,20 +142,22 @@
<!-- Database --> <!-- Database -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.9" />
<!-- Global Usings --> <!-- Global Usings -->
<Using Include="Npgsql" /> <Using Include="Npgsql" />
<Using Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore" /> <Using Include="Microsoft.EntityFrameworkCore" />
<Using Include="Microsoft.EntityFrameworkCore.Design" /> <Using Include="Microsoft.EntityFrameworkCore.Design" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" /> <Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
@@ -116,8 +165,8 @@
<!-- Email --> <!-- Email -->
<ItemGroup> <ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" /> <PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="MimeKit" Version="4.16.0" /> <PackageReference Include="MimeKit" Version="4.17.0" />
<!-- Global Usings--> <!-- Global Usings-->
<Using Include="MimeKit" /> <Using Include="MimeKit" />
@@ -136,8 +185,8 @@
<!-- Amazon S3 SDK --> <!-- Amazon S3 SDK -->
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.1" /> <PackageReference Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.4.7" />
<PackageReference Include="AWSSDK.S3" Version="4.0.23.4" /> <PackageReference Include="AWSSDK.S3" Version="4.0.24.4" />
<!-- global Usings --> <!-- global Usings -->
<Using Include="Amazon.S3" /> <Using Include="Amazon.S3" />
@@ -147,6 +196,14 @@
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="Microsoft.AspNetCore.DataProtection" />
<Using Include="System.Security.Cryptography.X509Certificates" />
<Using Include="Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage" />
<Using Include="System.Text.Json.Serialization" />
<Using Include="System.Reflection" />
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
<Using Include="Microsoft.AspNetCore.Routing" />
<Using Include="System.Web" />
<Using Include="Microsoft.IdentityModel.Tokens" /> <Using Include="Microsoft.IdentityModel.Tokens" />
<Using Include="Microsoft.AspNetCore.Http" /> <Using Include="Microsoft.AspNetCore.Http" />
<Using Include="HashidsNet" /> <Using Include="HashidsNet" />
@@ -1,8 +0,0 @@
namespace LiteCharms.Features.Models;
public sealed class PayfastWebhookPayload
{
public string? Amount { get; set; }
public string? ItemName { get; set; }
public string? MPaymentId { get; set; }
}
@@ -0,0 +1,13 @@
namespace LiteCharms.Features.Postgres;
public sealed class DataProtectionDbContext(DbContextOptions<DataProtectionDbContext> options) : DbContext(options), IDataProtectionKeyContext
{
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<DataProtectionKey>(entity => entity.ToTable(nameof(DataProtectionKeys), schema: "security"));
}
}
@@ -0,0 +1,20 @@
using static LiteCharms.Features.Extensions.Postgres;
namespace LiteCharms.Features.Postgres;
public sealed class DataProtectionDbContextFactory : IDesignTimeDbContextFactory<DataProtectionDbContext>
{
public DataProtectionDbContext CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddUserSecrets(typeof(DataProtectionDbContext).Assembly)
.AddEnvironmentVariables()
.Build();
var optionsBuilder = new DbContextOptionsBuilder<DataProtectionDbContext>();
optionsBuilder.UseNpgsql(configuration.GetConnectionString(DataProtectionDbConfigName));
return new DataProtectionDbContext(optionsBuilder.Options);
}
}
@@ -0,0 +1,48 @@
// <auto-generated />
using LiteCharms.Features.Postgres;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiteCharms.Features.Postgres.Migrations
{
[DbContext(typeof(DataProtectionDbContext))]
[Migration("20260614075149_Init")]
partial class Init
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text");
b.Property<string>("Xml")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("DataProtectionKeys", "security");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiteCharms.Features.Postgres.Migrations
{
/// <inheritdoc />
public sealed partial class Init : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "security");
migrationBuilder.CreateTable(
name: "DataProtectionKeys",
schema: "security",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FriendlyName = table.Column<string>(type: "text", nullable: true),
Xml = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DataProtectionKeys",
schema: "security");
}
}
}

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