Compare commits

...

248 Commits

Author SHA1 Message Date
VivianDee 9985a58b56 [fix]: Max daily loan count 2025-06-13 19:11:10 +01:00
ameye b41df3fe02 Merge branch 'rac_check_analysis' of DigiFi/digifi-BankToProductCore into master 2025-06-13 13:01:45 +00:00
VivianDee 08fe04b7b9 Update offer_analysis.py 2025-06-13 13:57:10 +01:00
vivian.d.simbrellang.com 79317632b6 Merge branch 'rac_check_analysis' of DigiFi/digifi-BankToProductCore into master 2025-06-13 12:33:10 +00:00
VivianDee 0d87036b92 Update config.py 2025-06-13 13:32:03 +01:00
ameye 1734007476 Merge branch 'rac_check_analysis' of DigiFi/digifi-BankToProductCore into master 2025-06-12 22:46:20 +00:00
VivianDee 6ef2be9625 [add]: Offer analysis update 2025-06-12 15:24:14 +01:00
VivianDee 48020f5284 Update eligibility_check.py 2025-06-12 14:35:32 +01:00
VivianDee 1a6ac6a37f Update offer_analysis.py 2025-06-12 14:00:15 +01:00
ameye bbf6953dc5 Merge branch 'rac_check_analysis' of DigiFi/digifi-BankToProductCore into master 2025-06-12 11:44:46 +00:00
VivianDee a2158a768e [update]: Rac Check analysis 2025-06-11 15:19:24 +01:00
CHIEFSOFT\ameye 0af1b7567b Rack Rules 2025-06-10 06:58:53 -04:00
vivian.d.simbrellang.com 4d08983ae3 Merge branch 'repayments_data_model' of DigiFi/digifi-BankToProductCore into master 2025-06-10 07:44:44 +00:00
VivianDee 70e15cd325 [add]: Repayments data Model 2025-06-10 08:42:41 +01:00
CHIEFSOFT\ameye e8d930f9b8 offer analysis 2025-06-05 23:06:09 -04:00
CHIEFSOFT\ameye c400f1d69d addded comments 2025-06-05 18:49:14 -04:00
vivian.d.simbrellang.com f7daa12531 Merge branch 'loan_and_repayment_tables_update' of DigiFi/digifi-BankToProductCore into master 2025-06-04 11:45:24 +00:00
VivianDee 3242a57586 [update]: Loan and repayment table 2025-06-04 12:43:38 +01:00
ameye 463c0a0def Merge branch 'middlewear-integration' of DigiFi/digifi-BankToProductCore into master 2025-06-04 02:36:14 +00:00
VivianDee c061c9b5a4 Update eligibility_check.py 2025-06-02 08:16:12 -04:00
VivianDee 201fa4202e Update eligibility_check.py 2025-06-02 08:07:15 -04:00
CHIEFSOFT\ameye bb4d7ac064 Log added 2025-05-31 21:31:12 -04:00
CHIEFSOFT\ameye 5a2161acaa added acont id 2025-05-31 20:44:42 -04:00
VivianDee 10138f66f3 Update eligibility_check.py 2025-05-30 08:25:40 -04:00
CHIEFSOFT\ameye 9ea0027f71 channel 2025-05-29 15:19:29 -04:00
VivianDee d1b8d15f31 Update eligibility_check.py 2025-05-29 13:00:51 -04:00
ameye f716b47603 Merge branch 'loan_reference' of DigiFi/digifi-BankToProductCore into master 2025-05-28 20:22:53 +00:00
VivianDee ec5db19e20 [add]: loan max amount 2025-05-28 20:19:50 +01:00
VivianDee 729cc26698 [add]: loan ref to repayment 2025-05-28 20:05:38 +01:00
ameye c95e2786b5 Merge branch 'loan_reference' of DigiFi/digifi-BankToProductCore into master 2025-05-27 11:14:42 +00:00
VivianDee 65472d3f07 [update]: loan status response 2025-05-27 02:02:22 +01:00
ameye 29b2697b0e Merge branch 'loan_reference' of DigiFi/digifi-BankToProductCore into master 2025-05-26 12:10:35 +00:00
VivianDee 7a2ff6586f Update provide_loan.py 2025-05-26 13:01:27 +01:00
VivianDee 066ced55b0 Update provide_loan.py 2025-05-26 12:56:49 +01:00
CHIEFSOFT\ameye f6c98d9bfd repayment status 2025-05-25 06:33:46 -04:00
CHIEFSOFT\ameye 9e22e0fcf3 customer with loan listing 2025-05-24 07:41:20 -04:00
CHIEFSOFT\ameye c9aba07e9c Funct log 2025-05-24 07:10:44 -04:00
CHIEFSOFT\ameye 20c9a5c713 Remove logger 2025-05-24 07:08:03 -04:00
CHIEFSOFT\ameye 1cb0d88cc2 No need for new logger 2025-05-24 07:05:18 -04:00
CHIEFSOFT\ameye 3e9d5d4089 Looking for customer 2025-05-24 07:02:50 -04:00
CHIEFSOFT\ameye 2ae49ace86 fix call 2025-05-24 06:04:19 -04:00
CHIEFSOFT\ameye e9de001340 fix string 2025-05-24 05:50:17 -04:00
CHIEFSOFT\ameye 0bdc11423f Rack analysis 2025-05-24 05:37:21 -04:00
CHIEFSOFT\ameye a4ed936392 racResponse 2025-05-24 05:01:20 -04:00
CHIEFSOFT\ameye 1a315b1d80 SIMBRELLA_API_KEY 2025-05-24 04:52:58 -04:00
CHIEFSOFT\ameye 4c30f81bfd Updated racc check payload 2025-05-24 04:42:58 -04:00
CHIEFSOFT\ameye 916261fa94 Fix url 2025-05-24 04:34:11 -04:00
CHIEFSOFT\ameye 081b73a932 Added logger 2025-05-24 04:31:25 -04:00
CHIEFSOFT\ameye 6852986ce5 end poit config 2025-05-24 04:27:07 -04:00
CHIEFSOFT\ameye 0038c22577 SIMBRELLA_ENDPOINT_RAC_CHECKS 2025-05-24 04:22:40 -04:00
ameye 326ee87b13 Merge branch 'loan_min_amount' of DigiFi/digifi-BankToProductCore into master 2025-05-21 14:58:49 +00:00
VivianDee ca22ee86f7 [add]: Loan min amount 2025-05-21 15:49:11 +01:00
ameye aa033a50a3 Merge branch 'Offers_update' of DigiFi/digifi-BankToProductCore into master 2025-05-19 15:15:13 +00:00
VivianDee 31b0367e6a [update]: Offers 2025-05-19 14:37:19 +01:00
VivianDee 89760f81ed [update]: Offers Model 2025-05-19 11:51:44 +01:00
CHIEFSOFT\ameye 701840abd1 Removed product id 2025-05-18 08:33:18 -04:00
ameye df6c42ca2d Merge branch 'Response_fix' of DigiFi/digifi-BankToProductCore into master 2025-05-16 19:21:59 +00:00
VivianDee a81313447b [add]: timestamp fix 2025-05-16 13:40:33 +01:00
ameye a321832d43 Merge branch 'Response_fix' of DigiFi/digifi-BankToProductCore into master 2025-05-14 21:50:14 +00:00
VivianDee 0369323dd6 Update transaction_offers.py 2025-05-14 20:05:20 +01:00
ameye d7b8addeb6 Merge branch 'Response_fix' of DigiFi/digifi-BankToProductCore into master 2025-05-12 18:01:27 +00:00
VivianDee 0db3f44c7b Update offer_analysis.py 2025-05-12 17:19:16 +01:00
VivianDee a7d465bd5c Merge branch 'Response_fix' of https://gitlab.chiefsoft.net/DigiFi/digifi-BankToProductCore into Response_fix 2025-05-12 17:12:09 +01:00
VivianDee a0ba49f208 [update]: Resposes 2025-05-12 16:31:55 +01:00
VivianDee 1e4f9102c8 Update provide_loan.py 2025-05-12 16:08:42 +01:00
VivianDee dee1edee40 Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-BankToProductCore 2025-05-12 16:06:24 +01:00
VivianDee 06b5f98f06 [update]: Resposes 2025-05-12 15:36:24 +01:00
CHIEFSOFT\ameye 746ca486da Fix data 2025-05-11 23:44:36 -04:00
CHIEFSOFT\ameye 3d81322515 Fix Intetrest Fee 2025-05-11 20:04:49 -04:00
CHIEFSOFT\ameye eeacffad9a Loan Reference added 2025-05-11 16:05:36 -04:00
CHIEFSOFT\ameye 11a239c67a Linked loan design 2025-05-10 20:08:27 -04:00
CHIEFSOFT\ameye 4ce0142ee0 Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-BankToProductCore
# Conflicts:
#	app/models/loan.py
2025-05-10 11:18:37 -04:00
ameye c268c4d92b Merge branch 'define_offers' of DigiFi/digifi-BankToProductCore into master 2025-05-10 14:43:43 +00:00
Fluxtra 6d743ea09b Update offer_analysis.py 2025-05-10 15:42:09 +01:00
ameye 89dd4bb191 Merge branch 'define_offers' of DigiFi/digifi-BankToProductCore into master 2025-05-10 14:35:14 +00:00
Fluxtra 4718c9c50b Update loan.py 2025-05-10 15:34:43 +01:00
Fluxtra feb97c3fa8 Update loan.py 2025-05-10 15:32:11 +01:00
Fluxtra 4bcaa3d13d Update loan.py 2025-05-10 15:30:15 +01:00
CHIEFSOFT\ameye b86bd3dece Fix ident 2025-05-10 10:06:14 -04:00
ameye a0a2c01a1c Merge branch 'define_offers' of DigiFi/digifi-BankToProductCore into master 2025-05-10 13:58:44 +00:00
Fluxtra d6faa14b54 Merge branch 'define_offers' of https://gitlab.chiefsoft.net/DigiFi/digifi-BankToProductCore into define_offers 2025-05-10 14:57:48 +01:00
Fluxtra 332c344efa [fix]: Indentation 2025-05-10 14:47:52 +01:00
CHIEFSOFT\ameye e377858c47 removed comments 2025-05-10 09:43:21 -04:00
ameye ed64d2c97c Merge branch 'define_offers' of DigiFi/digifi-BankToProductCore into master 2025-05-10 13:34:36 +00:00
Fluxtra bbdb7214d1 [add]: define offers update 2025-05-10 14:24:05 +01:00
ameye e9c50f75b1 disburse migration 2025-05-10 08:55:38 -04:00
CHIEFSOFT\ameye c330c3f0e7 disburse_date 2025-05-10 08:54:08 -04:00
CHIEFSOFT\ameye 976fb14614 This ensures the progragation of original transaction id 2025-05-10 07:53:35 -04:00
CHIEFSOFT\ameye 334cb0f2d6 Staered 2025-05-10 06:58:17 -04:00
CHIEFSOFT\ameye 40158b1c54 Analysis steps 2025-05-10 06:20:03 -04:00
ameye b7ae0e6baa New original transaction on offer 2025-05-10 05:55:53 -04:00
CHIEFSOFT\ameye 89b621b9a8 Original Transaction id on offers 2025-05-10 05:53:32 -04:00
CHIEFSOFT\ameye cc3cd5b72b Customer id fix 2025-05-10 05:33:04 -04:00
CHIEFSOFT\ameye f573d5e643 transaction_id 2025-05-10 05:17:28 -04:00
CHIEFSOFT\ameye 09b57d81a2 Moved offer decide 2025-05-10 05:14:53 -04:00
VivianDee 17db2cf8f9 Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-BankToProductCore 2025-05-10 08:53:09 +01:00
VivianDee f07866a884 [add]: transaction fix 2025-05-10 08:50:10 +01:00
CHIEFSOFT\ameye 6f8e269a50 clean up active loans 2025-05-09 23:20:05 -04:00
CHIEFSOFT\ameye 4435ca2776 original transaction id 2025-05-09 23:14:08 -04:00
CHIEFSOFT\ameye d851222024 Removed product id 2025-05-09 19:13:51 -04:00
CHIEFSOFT\ameye 52ab33f260 Logger 2025-05-09 19:10:32 -04:00
CHIEFSOFT\ameye af7e0f8624 Loggers 2025-05-09 19:07:43 -04:00
CHIEFSOFT\ameye c8ab2cd6ba transaction_offer_id 2025-05-09 18:58:27 -04:00
CHIEFSOFT\ameye 8ac22fa95f Offer id and interest amount 2025-05-09 18:52:25 -04:00
CHIEFSOFT\ameye 57207faf6f Intereat amount on sawagger 2025-05-09 18:42:19 -04:00
ameye 9a90609d33 Merge branch 'fix_loan_due_date' of DigiFi/digifi-BankToProductCore into master 2025-05-07 21:33:05 +00:00
VivianDee 50ca27abfe [add]: Offer analysis 2025-05-07 12:07:57 +01:00
VivianDee 74066bae56 [add]: offer analysis 2025-05-06 07:09:36 +01:00
VivianDee 4c4ef909c2 [add]: Offer analysis 2025-05-05 17:03:39 +01:00
VivianDee fdd7c58fab [fix]: loan due date 2025-05-05 12:17:26 +01:00
ameye 4bd163fb31 eligible amount migration 2025-05-03 17:59:17 -04:00
ameye d77181f627 Fix loan id 2025-05-03 17:44:38 -04:00
CHIEFSOFT\ameye 4a236fdd2f eligible_amount 2025-05-03 17:40:38 -04:00
CHIEFSOFT\ameye cae7ffd772 transaction offer 2025-05-03 17:08:55 -04:00
ameye 4f92f2a1a0 Select offer update 2025-05-03 16:48:55 -04:00
CHIEFSOFT\ameye 03adb266bb approved_amount 2025-05-03 09:04:42 -04:00
CHIEFSOFT\ameye d28bf95c97 random play on the data 2025-05-03 08:52:25 -04:00
ameye bd6edf52e1 Merge branch 'loan_schedule_fix' of DigiFi/digifi-BankToProductCore into master 2025-04-30 12:22:14 +00:00
VivianDee b1260895e0 Update provide_loan.py 2025-04-30 12:24:26 +01:00
VivianDee 2addf25a67 [fix]: Offer schedules 2025-04-30 12:11:32 +01:00
ameye 9dc431e66d Merge branch 'loan_schedule_fix' of DigiFi/digifi-BankToProductCore into master 2025-04-30 09:03:57 +00:00
VivianDee 9dae2d951c [add]: transaction id to loan schedules, [add]: tenor to loans 2025-04-30 09:57:49 +01:00
ameye a1d44e0e23 Transaction ID on payment Schedule table 2025-04-30 03:28:59 -04:00
ameye d9f972a425 Merge branch 'advanced_eligibility' of DigiFi/digifi-BankToProductCore into master 2025-04-29 19:51:23 +00:00
VivianDee 8aa2c86ea2 [fix]: transaction id 2025-04-29 16:52:51 +01:00
vivian.d.simbrellang.com 9c42332a83 Merge branch 'advanced_eligibility' of DigiFi/digifi-BankToProductCore into master 2025-04-29 07:27:41 +00:00
VivianDee 92eadbfa16 [update]: Eligibility check 2025-04-29 08:26:41 +01:00
ameye 0fbdebceb3 Merge branch 'DIG-loan-linking-001' of DigiFi/digifi-BankToProductCore into master 2025-04-26 19:40:29 +00:00
ameye 488a1b4bdd First step to linked loans 2025-04-26 15:39:37 -04:00
ameye cdc74d05c4 Merge branch 'DIG-rework-rates-fee-calulations-001' of DigiFi/digifi-BankToProductCore into master 2025-04-26 18:34:27 +00:00
ameye 1b92ede296 Reworked the fee calculations structure 2025-04-26 14:31:06 -04:00
ameye 7de4e3651f Merge branch 'DIG-move-data-new-columns-001' of DigiFi/digifi-BankToProductCore into master 2025-04-26 12:56:10 +00:00
ameye 5f9b1f4cb8 Added rates with offers 2025-04-26 08:53:06 -04:00
ameye ed95865834 Merge branch 'loan_repayment_schedules' of DigiFi/digifi-BankToProductCore into master 2025-04-25 15:58:25 +00:00
VivianDee 6973630845 Update loan_repayment_schedule.py 2025-04-25 16:14:44 +01:00
VivianDee 5d37ba30fb [update]: repayment schedule table 2025-04-25 16:04:44 +01:00
ameye e8044d8fed Merge branch 'loan_repayment_schedules' of DigiFi/digifi-BankToProductCore into master 2025-04-25 14:42:09 +00:00
VivianDee cf0502459b [chore]: remove redundant code 2025-04-25 15:34:46 +01:00
VivianDee 851422c335 [fix]: loan amount 2025-04-25 15:33:58 +01:00
VivianDee ddbabcaca9 [fix]: Repayment schedule model 2025-04-25 15:10:07 +01:00
Vivian Dee c216c55928 [add]: Lona repayment schedule 2025-04-25 14:29:13 +01:00
Vivian Dee 0995f08aea [update]: Loan and Offers 2025-04-25 13:31:11 +01:00
ameye e034c0ff9d Merge branch 'loan_repayment_dates' of DigiFi/digifi-BankToProductCore into master 2025-04-25 11:33:54 +00:00
VivianDee 4d4e4fcd3e Update base_service.py 2025-04-25 11:33:05 +01:00
VivianDee 1cce111d1f Update offer.py 2025-04-25 11:30:43 +01:00
VivianDee b9b7988877 Update base_service.py 2025-04-25 11:12:23 +01:00
VivianDee 841393c470 [fix]: Loan fees 2025-04-25 10:59:24 +01:00
VivianDee bbb903b27c [update]: RACCheck 2025-04-24 18:44:31 +01:00
VivianDee c895cc36e0 [add]: Loan table extention 2025-04-24 18:29:38 +01:00
CHIEFSOFT\ameye 67c6d909f8 Adjusted the respose 2025-04-24 12:44:18 -04:00
ameye e08dfe9894 Merge branch 'loan_repayment_dates' of DigiFi/digifi-BankToProductCore into master 2025-04-24 10:28:10 +00:00
VivianDee 7d691db7a5 [update]: Select Offer 2025-04-23 20:59:07 +01:00
Vivian Dee 4b92c33d5a [fix]: loan charges and instalment amount 2025-04-23 18:57:22 +01:00
Vivian Dee 8cfa957cc0 [update]: Select Offer 2025-04-23 18:35:47 +01:00
ameye 5768b537b1 Merge branch 'charges_model' of DigiFi/digifi-BankToProductCore into master 2025-04-17 16:12:09 +00:00
VivianDee f2f592d507 Update loan_charge.py 2025-04-17 16:51:01 +01:00
ameye bc894c7856 Merge branch 'charges_model' of DigiFi/digifi-BankToProductCore into master 2025-04-17 15:13:48 +00:00
VivianDee 0587efb95c Update entrypoint.sh 2025-04-17 15:52:47 +01:00
VivianDee 57fa4d72d9 [add]: Loan charge 2025-04-17 15:52:14 +01:00
VivianDee 75f71a807d [add]: Charges 2025-04-17 12:22:14 +01:00
VivianDee b6a4af5cc6 [add]: jmeter file 2025-04-17 11:53:36 +01:00
ameye 829bd976b2 Merge branch 'loan_charges_on_loans' of DigiFi/digifi-BankToProductCore into master 2025-04-17 10:45:18 +00:00
VivianDee 9a1c81ab10 [fix]: Missing Transaction ID 2025-04-17 11:44:42 +01:00
VivianDee f461b826e6 [fix]: Refernce amount 2025-04-17 11:39:43 +01:00
VivianDee 2c8fda1792 [add]: transaction id and due date 2025-04-17 11:31:05 +01:00
ameye e04f54bf83 Merge branch 'loan_charges_on_loans' of DigiFi/digifi-BankToProductCore into master 2025-04-17 10:03:16 +00:00
VivianDee e14e290ff9 [update]: RACChecks 2025-04-16 22:46:48 +01:00
VivianDee 93ed8b3d17 [add]: loan charges and offers. Fix RACCheck 2025-04-16 21:36:26 +01:00
VivianDee 359621dc9d [add]: Offers 2025-04-16 19:16:03 +01:00
Vivian Dee 9cfa4a67b1 [update]: Loan Charge 2025-04-16 15:58:56 +01:00
Vivian Dee f55f179672 [update]: offers 2025-04-16 15:57:03 +01:00
Vivian Dee 86801b13fb [add]: Offers Model 2025-04-16 15:54:39 +01:00
VivianDee aba5a02197 [update]: Loan Charge model 2025-04-16 13:30:20 +01:00
VivianDee 142a7eb886 [add]: Loan charges model 2025-04-16 13:23:23 +01:00
VivianDee cb18234008 [add]: Loan charges model 2025-04-16 12:10:25 +01:00
CHIEFSOFT\ameye 46b8d99409 fix name to transactionId 2025-04-16 05:14:16 -04:00
CHIEFSOFT\ameye 3c0443d0c7 Transaction id added 2025-04-16 05:12:22 -04:00
CHIEFSOFT\ameye 7bee948c83 loan_def 2025-04-16 04:36:32 -04:00
ameye 8ab485d920 Track transaction before create loan 2025-04-15 20:22:47 -04:00
ameye 9df8e31fdd Transaction Id on repayment 2025-04-15 17:16:30 -04:00
vivian.d.simbrellang.com a2399a2eae Merge branch 'loan_repayment_event' of DigiFi/digifi-BankToProductCore into master 2025-04-14 20:05:16 +00:00
VivianDee a6e7eaac3c [add]: accountId on loan and repayment 2025-04-14 21:04:17 +01:00
VivianDee 482a860bd2 [add]: account_id to loan status and repayment 2025-04-14 15:26:46 +01:00
ameye 7c10d8263d Merge branch 'loan_repayment_event' of DigiFi/digifi-BankToProductCore into master 2025-04-11 15:08:47 +00:00
VivianDee 6476b62d3c [update]: Loan Status 2025-04-11 15:54:26 +01:00
VivianDee e69335cb71 [add]: transaction fix 2025-04-11 13:15:56 +01:00
ameye 5e49b4bb35 Merge branch 'loan_repayment_event' of DigiFi/digifi-BankToProductCore into master 2025-04-11 08:25:15 +00:00
VivianDee 8a018545ec Update base_service.py 2025-04-11 00:17:59 +01:00
VivianDee 3031251519 Update base_service.py 2025-04-11 00:17:38 +01:00
VivianDee 1d97304f4e [update]: Loan status 2025-04-11 00:14:02 +01:00
VivianDee 9f9512b060 [add]: loan status 2025-04-11 00:02:35 +01:00
VivianDee d397c834f4 [add]: db sessions 2025-04-10 23:45:40 +01:00
VivianDee a196d4d3c4 [add]: ref_id and ref_model for transactions. And db session and rollback for transactions 2025-04-10 23:06:51 +01:00
ameye b8190a0050 Merge branch 'loan_repayment_event' of DigiFi/digifi-BankToProductCore into master 2025-04-10 17:42:43 +00:00
VivianDee e5320c075e [add]: Update loan status after repayment 2025-04-10 18:35:26 +01:00
VivianDee 1081467f6f [add]: DB migrations fix and Save loan repayment details 2025-04-10 17:50:25 +01:00
VivianDee f252e33be2 [add]: Loan repayment event 2025-04-10 17:15:19 +01:00
VivianDee 6f15ae97f4 Merge branch 'master' of https://gitlab.chiefsoft.net/DigiFi/digifi-BankToProductCore 2025-04-10 17:03:24 +01:00
VivianDee 7cea5390c0 [add]: loan_repayment event 2025-04-10 17:02:54 +01:00
ameye 729ebd5d08 Merge branch 'loan_repayment_fix' of DigiFi/digifi-BankToProductCore into master 2025-04-10 13:07:36 +00:00
VivianDee 7026c8378b [fix]: Loan id 2025-04-10 13:23:47 +01:00
VivianDee b22d2f358e [fix]: Removed out product and lien amount 2025-04-10 12:58:30 +01:00
VivianDee f13ca508c2 Update provide_loan.py 2025-04-10 12:40:19 +01:00
VivianDee 87f1fa2152 [fix]: save loan 2025-04-10 12:35:46 +01:00
VivianDee 2c34c16c34 [fix]: Missing account id 2025-04-10 12:27:57 +01:00
VivianDee 1b973322c9 [add]: Loan details and repayment fix 2025-04-10 12:20:57 +01:00
CHIEFSOFT\ameye edd19b9a39 SQL Storage 2025-04-09 05:41:33 -04:00
CHIEFSOFT\ameye 773dedcd5b Arrange swagger 2025-04-07 15:29:29 -04:00
CHIEFSOFT\ameye eb4bcb025d Adjusted swagger file 2025-04-07 15:27:01 -04:00
CHIEFSOFT\ameye 37d808c144 offers fix 2025-04-07 14:02:17 -04:00
CHIEFSOFT\ameye 1f86d30c3a msisdn 2025-04-07 13:27:08 -04:00
CHIEFSOFT\ameye 946818dae6 Transaction ID added to return 2025-04-07 13:24:07 -04:00
CHIEFSOFT\ameye 333e48d32a Filters issues 2025-04-06 20:55:57 -04:00
CHIEFSOFT\ameye 6e24b498dc from sqlalchemy import and_, or_, not_ 2025-04-06 20:49:02 -04:00
CHIEFSOFT\ameye cbf57f4bfb Multiple filter 2025-04-06 20:46:25 -04:00
CHIEFSOFT\ameye bae92d36c6 transaction_id 2025-04-06 00:58:57 -04:00
CHIEFSOFT\ameye dd2da9c462 fix data 2025-04-06 00:53:14 -04:00
CHIEFSOFT\ameye f20563a9b1 transaction.id 2025-04-06 00:32:46 -04:00
CHIEFSOFT\ameye 85671081ec transaction_id 2025-04-06 00:28:44 -04:00
CHIEFSOFT\ameye 53cff748ed Return transactuin is 2025-04-06 00:18:12 -04:00
CHIEFSOFT\ameye 3b098969e2 removed migratin file 2025-04-06 00:04:29 -04:00
CHIEFSOFT\ameye 751bcb7d28 db.create_all() 2025-04-06 00:00:52 -04:00
CHIEFSOFT\ameye 25ccb240ca Fixed serialm 2025-04-05 23:55:05 -04:00
CHIEFSOFT\ameye de4554e247 reverst 2025-04-05 23:51:53 -04:00
CHIEFSOFT\ameye e1b3f4930e transactions 2025-04-05 23:36:52 -04:00
CHIEFSOFT\ameye 900d7b509c identity column 2025-04-05 23:33:51 -04:00
CHIEFSOFT\ameye 31ffbe686a table change 2025-04-05 23:30:48 -04:00
CHIEFSOFT\ameye 7b0d19c0e0 Review error state - error in test script 2025-04-05 22:26:47 -04:00
CHIEFSOFT\ameye 7087254859 test error 2025-04-05 22:23:49 -04:00
CHIEFSOFT\ameye d2d9603914 Check for error is not valid 2025-04-05 21:58:35 -04:00
CHIEFSOFT\ameye f2b5703bcb Seen responses 2025-04-05 21:55:41 -04:00
CHIEFSOFT\ameye 49c3422c94 import json 2025-04-05 21:51:41 -04:00
CHIEFSOFT\ameye 07f1db2050 Json dump result 2025-04-05 21:49:42 -04:00
CHIEFSOFT\ameye 7cbe3aefea result management 2025-04-05 21:44:33 -04:00
CHIEFSOFT\ameye e7ae1f6732 Respose code check 2025-04-05 21:19:50 -04:00
CHIEFSOFT\ameye 30233b7283 test Header for basic auths 2025-04-05 21:14:03 -04:00
CHIEFSOFT\ameye 3a30e0abff VALID_API_KEY 2025-04-05 21:03:16 -04:00
CHIEFSOFT\ameye d3b9708216 auth=auth 2025-04-05 20:59:15 -04:00
CHIEFSOFT\ameye 0e8bc63b4d response tracking to bank emulator - TEST 2025-04-05 19:11:58 -04:00
CHIEFSOFT\ameye cbc9d6363e Tracing rack chck issue 2025-04-05 18:59:35 -04:00
CHIEFSOFT\ameye 24fe303304 Missing on Api CALL - lienAmount 2025-04-05 18:47:32 -04:00
ameye 681ac19028 Merge branch 'vivian-update-01' of DigiFi/digifi-BankToProductCore into master 2025-04-04 21:48:47 +00:00
VivianDee d2ffe1c439 Merge branch 'bank-simbrella-calls' into vivian-update-01 2025-04-04 09:56:37 +01:00
VivianDee 12fc6d0ab6 Update .gitignore 2025-04-04 09:40:21 +01:00
VivianDee 04e5548a4a Create kafka.py 2025-04-04 09:39:36 +01:00
CHIEFSOFT\ameye 823ae9e2a7 Added uRL 2025-04-03 19:10:40 -04:00
VivianDee 79ac972841 [add]: threading for Kafa integration 2025-04-03 12:35:50 +01:00
VivianDee b180f8411d [add]: Kafka Integration 2025-04-03 10:49:21 +01:00
93 changed files with 4326 additions and 714 deletions
+35
View File
@@ -0,0 +1,35 @@
# Environment Variables
BASIC_AUTH_USERNAME=user
BASIC_AUTH_PASSWORD=password
#swagger Configuration
SWAGGER_URL="/documentation"
API_URL="/swagger.json"
# Database Configuration
DATABASE_USER=firstadvance
DATABASE_PASSWORD=FirstAdvance!
DATABASE_HOST=dev-data.simbrellang.net
DATABASE_PORT=10532
DATABASE_NAME=firstadvancedev
# DATABASE_HOST=10.20.30.60
# DATABASE_USER=firstadvance
# DATABASE_PASSWORD=firstadvance
# DATABASE_NAME=firstadvancedev
# DATABASE_PORT=5432
# Flask Configuration
FLASK_APP=wsgi.py
FLASK_ENV=development
APP_PORT=4500
# Bank Call Service Connection
SIMBRELLA_BASE_URL="https://bank-emulator.dev.simbrellang.net"
VALID_APP_ID=app1
VALID_API_KEY=test-api-key-12345
# Event Bus Broker Configuration
KAFKA_BROKER="10.0.0.246:9092"
+2 -1
View File
@@ -4,4 +4,5 @@ app.log
.DS_Store
migrations/__pycache__/
migrations/*.pycg
./vscode
./vscode
.vscode/settings.json
+24 -24
View File
@@ -1,24 +1,24 @@
{
"editor.lineNumbers": "off",
"editor.padding.top": 3,
"editor.padding.bottom": 3,
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.fontSize": 14,
"editor.lineHeight": 4.5,
"editor.suggestFontSize": 15,
// "editor.suggestLineHeight": 4,
"breadcrumbs.enabled": false,
"workbench.tips.enabled": false,
"workbench.statusBar.visible": false,
// "workbench.editor.showTabs": "single",
"git.enableSmartCommit": true,
"workbench.editor.editorActionsLocation": "hidden",
// "workbench.activityBar.location": "hidden",
"workbench.editor.enablePreviewFromQuickOpen": false,
"editor.lightbulb.enabled": "off",
"editor.selectionHighlight": false,
"editor.overviewRulerBorder": false,
"editor.renderLineHighlight": "none",
"editor.occurrencesHighlight": "off"
}
// {
// "editor.lineNumbers": "off",
// "editor.padding.top": 3,
// "editor.padding.bottom": 3,
// "editor.formatOnSave": true,
// "editor.formatOnPaste": true,
// "editor.fontSize": 14,
// "editor.lineHeight": 4.5,
// "editor.suggestFontSize": 15,
// // "editor.suggestLineHeight": 4,
// "breadcrumbs.enabled": false,
// "workbench.tips.enabled": false,
// "workbench.statusBar.visible": false,
// // "workbench.editor.showTabs": "single",
// "git.enableSmartCommit": true,
// "workbench.editor.editorActionsLocation": "hidden",
// // "workbench.activityBar.location": "hidden",
// "workbench.editor.enablePreviewFromQuickOpen": false,
// "editor.lightbulb.enabled": "off",
// "editor.selectionHighlight": false,
// "editor.overviewRulerBorder": false,
// "editor.renderLineHighlight": "none",
// "editor.occurrencesHighlight": "off"
// }
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE transactions (
id SERIAL,
transaction_id VARCHAR(50) NOT NULL,
account_id VARCHAR(50) NOT NULL,
type VARCHAR(50) NOT NULL,
channel VARCHAR(8) NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
ALTER TABLE ONLY transactions
ADD CONSTRAINT transactions_id_key UNIQUE (id);
+1
View File
@@ -26,6 +26,7 @@ def create_app():
CORS(app)
JWTManager(app)
CORS(app, supports_credentials=True)
# Swagger Doc
SWAGGER_URL = app.config.get("SWAGGER_URL")
+2 -1
View File
@@ -1 +1,2 @@
from .transaction_type import TransactionType
from .transaction_type import TransactionType
from .loan_status import LoanStatus
+7
View File
@@ -0,0 +1,7 @@
from enum import Enum
class LoanStatus(str, Enum):
PENDING = "pending"
ACTIVE = "active"
START_REPAY = "start_repay"
REPAID = "repaid"
+56 -195
View File
@@ -1,251 +1,112 @@
from flask import jsonify
from typing import List, Dict, Union, Optional, Any
from typing import Optional, Union, Dict, List, Any
class ResponseHelper:
"""
A helper class for building standardized JSON responses in Flask.
A helper class for building standardized JSON responses using resultCode and resultDescription.
"""
@staticmethod
def build_response(
status: bool,
message: str,
data: Optional[Union[Dict, List, str]] = None,
status_code: int = 200,
error: Optional[Union[Dict, str]] = None,
result_code: str,
result_description: str,
data: Optional[Union[Dict, List, str]] = None
) -> Dict[str, Any]:
"""
Build a standardized JSON response.
Args:
status (bool): Indicates whether the request was successful.
message (str): A message describing the result of the request.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
status_code (int): The HTTP status code for the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
response = {
"status": status,
"statusCode": status_code,
"message": message,
"data": data if data is not None else {},
"error": error if error is not None else {},
"resultCode": result_code,
"resultDescription": result_description
}
return jsonify(response), status_code
if isinstance(data, dict):
response.update(data)
return jsonify(response)
@staticmethod
def success(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Successful",
status_code: int = 200,
error: Optional[Union[Dict, str]] = None,
result_description: str = "Successful",
result_code: str = "00",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a success response.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
status_code (int): The HTTP status code for the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, status_code, error)
return ResponseHelper.build_response(result_code, result_description, data)
@staticmethod
def error(
message: str = "An error occurred",
status_code: int = 400,
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
result_description: str = "An error occurred",
result_code: str = "01",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return an error response.
Args:
message (str): A message describing the error.
status_code (int): The HTTP status code for the response.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, status_code, error)
return ResponseHelper.build_response(result_code, result_description, data)
@staticmethod
def created(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Resource created successfully",
error: Optional[Union[Dict, str]] = None,
result_description: str = "Resource created successfully",
result_code: str = "00",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for a created resource.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, 201, error)
return ResponseHelper.build_response(result_code, result_description, data)
@staticmethod
def updated(
data: Optional[Union[Dict, List, str]] = None,
message: str = "Resource updated successfully",
error: Optional[Union[Dict, str]] = None,
result_description: str = "Resource updated successfully",
result_code: str = "00",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for an updated resource.
Args:
data (Optional[Union[Dict, List, str]]): The data to return in the response.
message (str): A message describing the result of the request.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(True, message, data, 200, error)
return ResponseHelper.build_response(result_code, result_description, data)
@staticmethod
def internal_server_error(
message: str = "Internal Server Error",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
result_description: str = "Internal Server Error",
result_code: str = "500",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for an internal server error.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 500, error)
return ResponseHelper.build_response(result_code, result_description, data)
@staticmethod
def unauthorized(
message: str = "Unauthorized",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
result_description: str = "Unauthorized",
result_code: str = "401",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for an unauthorized request.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 401, error)
return ResponseHelper.build_response(result_code, result_description, data)
@staticmethod
def forbidden(
message: str = "Forbidden",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
result_description: str = "Forbidden",
result_code: str = "403",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for a forbidden request.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 403, error)
return ResponseHelper.build_response(result_code, result_description, data)
@staticmethod
def not_found(
message: str = "Resource not found",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
result_description: str = "Resource not found",
result_code: str = "404",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for a not found resource.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 404, error)
return ResponseHelper.build_response(result_code, result_description, data)
@staticmethod
def unprocessable_entity(
message: str = "Unprocessable entity",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
result_description: str = "Unprocessable entity",
result_code: str = "422",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for an unprocessable entity.
return ResponseHelper.build_response(result_code, result_description, data)
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 422, error)
@staticmethod
def method_not_allowed(
message: str = "Method Not Allowed",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
result_description: str = "Method Not Allowed",
result_code: str = "405",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for a method not allowed error.
return ResponseHelper.build_response(result_code, result_description, data)
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 405, error)
@staticmethod
def bad_request(
message: str = "Bad Request",
data: Optional[Union[Dict, List, str]] = None,
error: Optional[Union[Dict, str]] = None,
result_description: str = "Bad Request",
result_code: str = "400",
data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Return a response for a bad request error.
Args:
message (str): A message describing the error.
data (Optional[Union[Dict, List, str]]): The data to return in the response.
error (Optional[Union[Dict, str]]): Any error details to include in the response.
Returns:
Dict[str, Any]: A dictionary representing the JSON response.
"""
return ResponseHelper.build_response(False, message, data, 400, error)
return ResponseHelper.build_response(result_code, result_description, data)
+2 -1
View File
@@ -1 +1,2 @@
from .simbrella import SimbrellaClient
from .simbrella import SimbrellaIntegration
from .kafka import KafkaIntegration
+82
View File
@@ -0,0 +1,82 @@
from confluent_kafka import Producer
import json
import logging
from app.config import settings
logger = logging.getLogger(__name__)
class KafkaIntegration:
_producer = None
_config = {
"bootstrap.servers": settings.KAFKA_BROKER,
"client.id": "loan-service-producer",
"acks": "all",
"retries": 3,
"debug": "broker,topic,msg",
}
@staticmethod
def _get_producer():
"""Kafka producer"""
if not KafkaIntegration._producer:
KafkaIntegration._producer = Producer(KafkaIntegration._config)
logger.info(
f"Connected to Kafka broker at {KafkaIntegration._config['bootstrap.servers']}"
)
return KafkaIntegration._producer
@staticmethod
def delivery_report(err, msg):
"""Called once for each message produced"""
if err is not None:
logger.error(f'Message delivery failed: {err}')
raise RuntimeError(f"Message delivery failed: {err}")
else:
logger.debug(f'Message delivered to {msg.topic()} [{msg.partition()}] @ offset {msg.offset()}')
@staticmethod
def send_loan_request(loan_data, request_id, topic):
"""
Send loan request to topic
Args:
loan_data: Loan request payload as dict
request_id: Unique request identifier (used as Kafka key)
"""
try:
# Proceed to send loan request to Kafka
producer = KafkaIntegration._get_producer()
# Sending loan request message to Kafka
producer.produce(
topic=topic,
key=str(request_id),
value=json.dumps(loan_data).encode("utf-8"),
callback=KafkaIntegration.delivery_report,
)
producer.poll(0)
logger.info(f"Loan request {request_id} queued for processing")
except Exception as e:
logger.error(
f"Failed to send loan request to Kafka: {str(e)}", exc_info=True
)
raise Exception(f"Failed to send loan request to Kafka: {str(e)}")
@staticmethod
def flush():
"""Shutdown"""
producer = KafkaIntegration._get_producer()
producer.flush()
+27 -28
View File
@@ -1,46 +1,45 @@
import requests
import httpx
import json
from app.utils.logger import logger
from app.config import settings
import logging
class SimbrellaClient:
class SimbrellaIntegration:
BASE_URL = settings.SIMBRELLA_BASE_URL
ENDPOINT_RAC_CHECKS = settings.SIMBRELLA_ENDPOINT_RAC_CHECKS
@staticmethod
def rac_check(customer_id, account_id, transaction_id):
"""
Calls the RACCheck endpoit
"""
url = f"{SimbrellaClient.BASE_URL}/RACCheck"
url = f"{SimbrellaIntegration.BASE_URL}/{SimbrellaIntegration.ENDPOINT_RAC_CHECKS}"
logger.info(f"Contacting Rack Checks EndPoint: {str(url)}", exc_info=True)
payload = {
"customerId": customer_id,
"accountId": account_id,
"transactionId": transaction_id,
"RAC_Array": [
{
"salaryAccount": True,
"bvn": "12345678901",
"crc": False,
"crms": True,
"accountStatus": "active",
"lien": False,
"noBouncedCheck": True,
"existingLoan": False,
"whitelist": True,
"noPastDueSalaryLoan": True,
"noPastDueOtherLoans": False
}
]
"transactionId": str(transaction_id),
"fbnTransactionId": str(transaction_id),
"countryCode": "NG",
"channel": "USSD"
}
headers = {
"Content-Type": "application/json",
"x-api-key": f"{settings.VALID_API_KEY}",
"App-Id": f"{settings.VALID_APP_ID}",
}
try:
response = requests.post(url, json=payload, timeout=10)
response = httpx.post(url, json=payload, headers=headers, timeout=10.0)
# Raise an error for non-200 responses
# response.raise_for_status()
logger.info(f"This is Response: {str(response)}", exc_info=True)
return response.json()
except requests.exceptions.RequestException as err:
logger.error(f"RACCheck API call failed: {str(err)}", exc_info=True)
return {"error": "RACCheck API error", "details": str(err)}
return response
except Exception as e:
logger.error(f"RACCheck API call failed: {str(e)}", exc_info=True)
raise Exception(f"RACCheck API call failed: {str(e)}")
+2 -2
View File
@@ -15,12 +15,12 @@ def require_app_id(f):
if not app_id:
logger.error("Unauthorized access: Missing App-ID.")
return jsonify({"message": "Invalid request parameters"}), 400
return jsonify({"message": "Invalid request"}), 400
if app_id != VALID_APP_ID:
logger.error(f"Unauthorized access: Invalid App-ID {app_id}.")
return jsonify({"message": "Invalid request parameters"}), 400
return jsonify({"message": "Invalid request"}), 400
return f(*args, **kwargs)
+1 -1
View File
@@ -11,7 +11,7 @@ def require_auth(f):
def decorated(*args, **kwargs):
auth = request.headers.get('Authorization')
if not auth or not check_auth(auth):
return jsonify({"message": "Invalid request parameters"}), 401
return jsonify({"message": "Invalid request"}), 401
return f(*args, **kwargs)
return decorated
+1 -1
View File
@@ -4,4 +4,4 @@ from flask import request, jsonify
def enforce_json():
"""Middleware to enforce JSON Content-Type for incoming requests"""
if request.method in ["POST", "PUT", "PATCH"] and request.content_type != "application/json":
return jsonify({"message": "Invalid request parameters"}), 400
return jsonify({"message": "Invalid request"}), 400
+2 -2
View File
@@ -14,11 +14,11 @@ def require_api_key(f):
if not api_key:
logger.error("Unauthorized access: Missing API key.")
return jsonify({"message": "Invalid request parameters"}), 400
return jsonify({"message": "Invalid request"}), 400
if api_key != VALID_API_KEY:
logger.error("Unauthorized access: Invalid API key.")
return jsonify({"message": "Invalid request parameters"}), 400
return jsonify({"message": "Invalid request"}), 400
return f(*args, **kwargs)
+1
View File
@@ -88,6 +88,7 @@ def loan_status():
@jwt_required()
def repayment():
data = request.get_json()
logger.error(f"HERE 0000a **** ")
# logger.info(f"Repayment request received: {data}")
response = RepaymentService.process_request(data)
return response
+1
View File
@@ -3,6 +3,7 @@ from marshmallow import Schema, fields
# Loan Information Schema
class LoanStatusSchema(Schema):
transactionId = fields.Str(required=True)
accountId = fields.Str(required=True)
customerId = fields.Str(required=True)
msisdn = fields.Str(required=False)
channel = fields.Str(required=True)
+3 -3
View File
@@ -8,9 +8,9 @@ class ProvideLoanSchema(Schema):
customerId = fields.Str(required=True)
accountId = fields.Str(required=True)
msisdn = fields.Str(required=False)
productId = fields.Str(required=True)
lienAmount = fields.Float(required=True)
# productId = fields.Str(required=True)
# lienAmount = fields.Float(required=True)
requestedAmount = fields.Float(required=True)
collectionType = fields.Int(required=True)
offerId = fields.Int(required=True)
offerId = fields.Str(required=True)
channel = fields.Str(required=True)
+3 -2
View File
@@ -5,7 +5,8 @@ class RepaymentSchema(Schema):
type = fields.Str(required=False)
msisdn = fields.Str(required=False) #optional
debtId = fields.Str(required=True)
productId = fields.Str(required=True)
transactionId = fields.Str(required=True)
accountId = fields.Str(required=True)
customerId = fields.Str(required=True)
channel = fields.Str(required=True)
loanRef = fields.Str(required=True)
initiatedBy = fields.Str(required=False)
+1
View File
@@ -9,5 +9,6 @@ class SelectOfferSchema(Schema):
msisdn = fields.Str(required=True)
requestedAmount = fields.Float(required=True)
productId = fields.Str(required=True)
offerId = fields.Str(required=True)
channel = fields.Str(required=True)
+1
View File
@@ -6,3 +6,4 @@ from app.api.services.repayment import RepaymentService
from app.api.services.customer_consent import CustomerConsentService
from app.api.services.notification_callback import NotificationCallbackService
from app.api.services.authorization import AuthorizationService
from app.api.services.offer_analysis import OfferAnalysis
+7 -7
View File
@@ -34,7 +34,7 @@ class AuthorizationService(BaseService):
logger.info("Processing Authorization request")
if not data:
return ResponseHelper.bad_request(message="Missing JSON in request")
return ResponseHelper.bad_request(result_description="Missing JSON in request")
# Validate input data using the Authorization schema
schema = AuthorizeRequestSchema()
@@ -44,7 +44,7 @@ class AuthorizationService(BaseService):
validated_data["username"] != USERNAME
or validated_data["password"] != PASSWORD
):
return ResponseHelper.unauthorized(message="Invalid credentials")
return ResponseHelper.unauthorized(result_description="Invalid credentials")
access_token = create_access_token(identity=validated_data["username"])
refresh_token = create_refresh_token(identity=validated_data["username"])
@@ -56,17 +56,17 @@ class AuthorizationService(BaseService):
}
return ResponseHelper.success(
data=response_data, message="Authorization processed successfully"
data={"data": response_data}, result_description="Authorization processed successfully"
)
except ValidationError as e:
logger.error(f"Validation error: {e}")
return ResponseHelper.bad_request(message=f"Validation error: {e}")
return ResponseHelper.bad_request(result_description=f"Validation error: {e}")
except Exception as e:
logger.error(f"Error processing Authorization request: {e}")
return ResponseHelper.internal_server_error(
message=f"Error processing Authorization request: {e}"
result_description=f"Error processing Authorization request: {e}"
)
@staticmethod
@@ -92,11 +92,11 @@ class AuthorizationService(BaseService):
}
return ResponseHelper.success(
data=response_data, message="RefreshToken processed successfully"
data={"data": response_data}, result_description="RefreshToken processed successfully"
)
except Exception as e:
logger.error(f"Error processing RefreshToken request: {e}")
return ResponseHelper.internal_server_error(
message=f"Error processing RefreshToken request: {e}"
result_description=f"Error processing RefreshToken request: {e}"
)
+124 -4
View File
@@ -3,6 +3,7 @@ from app.api.enums import TransactionType
from flask import jsonify
from marshmallow import ValidationError
import logging
from app.api.integrations import KafkaIntegration
logger = logging.getLogger(__name__)
@@ -47,9 +48,128 @@ class BaseService:
"""
Create a new transaction.
"""
channel = "USSD" if validated_data.get("channel") is None else validated_data.get("channel")
return Transaction.create_transaction(
id=validated_data.get("transactionId"),
account_id=validated_data.get("accountId"),
type=cls.TRANSACTION_TYPE,
channel=validated_data.get("channel"),
transaction_id = validated_data.get("transactionId"),
customer_id = validated_data.get('customerId', None),
account_id = validated_data.get("accountId", None),
type = cls.TRANSACTION_TYPE,
channel = channel,
)
@classmethod
def async_send_to_kafka(cls, loan_data, request_id, topic):
KafkaIntegration.send_loan_request(loan_data = loan_data, request_id = request_id, topic = topic)
KafkaIntegration.flush()
@classmethod
def calculate_charges(cls, offer, amount):
"""
Calculates and returns the charges for the given offer and amount.
Args:
offer (Offer): The offer object that contains the charges.
amount (float): The requested loan amount.
Returns:
dict: A dictionary containing the calculated charges.
"""
if not offer or not offer.charges:
logger.error(f"No charges found for offer ID {offer.id}")
return {"error": "No charges found for the offer"}
loan_charges = offer.charges
tenor = offer.schedule # offer.tenor // 30 # Convert to months
interest = cls.get_charge_detail(rates = offer.interest_rate, charges = loan_charges, code = "INTEREST", amount = amount)
management = cls.get_charge_detail(rates = offer.management_rate, charges = loan_charges, code = "MGTFEE", amount = amount)
insurance = cls.get_charge_detail(rates = offer.insurance_rate, charges = loan_charges, code = "INSURANCE", amount = amount)
vat = cls.get_charge_detail(rates = offer.vat_rate, charges = loan_charges, code = "VAT", amount = amount, management_fee = management["fee"])
# Separate fees into upfront and postpaid
upfront_fees = [
fee["fee"]
for fee in [interest, management, insurance, vat]
if fee["due_days"] == 0
]
postpaid_fees = [
fee["fee"]
for fee in [interest, management, insurance, vat]
if fee["due_days"] != 0
]
vat_test = vat["fee"]
logger.info(f"VAT fee == *************** : {vat_test}")
# Up-front payment: (only those fees due immediately i.e due_days == 0)
# upfront_payment = sum(upfront_fees)
if offer.schedule == 1:
upfront_payment = vat["fee"] + management["fee"] + insurance["fee"] + interest["fee"]
interest_amount = interest["fee"]
repayment_amount = amount
else:
upfront_payment = vat["fee"] + insurance["fee"]+management["fee"]
interest_amount = interest["fee"]*offer.schedule
repayment_amount = amount + interest_amount
# Repayment amount: (principal + only those fees not due immediately i.e due_days != 0)
# repayment_amount = amount + (sum(postpaid_fees) * tenor)
# Total amount: (upfront_payment + repayment_amount)
total_amount = upfront_payment + repayment_amount
# Calculate the installment amount
installment_amount = repayment_amount / offer.schedule
return {
"interest": interest,
"interest_amount": interest_amount,
"management": management,
"insurance": insurance,
"vat": vat,
"upfront_payment": round(upfront_payment, 2),
"repayment_amount": round(repayment_amount, 2),
"installment_amount": round(installment_amount, 2),
"total_amount": round(total_amount, 2)
}
@classmethod
def get_charge_detail(cls, rates, charges, code, amount, management_fee= 0.0):
"""
Get details for a specific charge code from a list of loan charges.
Returns default values if not found.
"""
fee = 0.0
if code == "VAT" and management_fee > 0:
fee = management_fee * rates / 100
else:
fee = amount * rates / 100
return {
"rate": rates,
"fee": round(fee, 2),
"due_days": 30,
"code": code,
"description" : "have no idea how to get this yet"
}
# if charge.code == code:
# if code == "VAT" and management_fee > 0:
# fee = management_fee * rates / 100
# else:
# fee = amount * rates / 100
#
# return {
# "rate": rates,
# "fee": round(fee, 2),
# "due_days": charge.due
# }
# return {"rate": 0, "fee": 0, "due_days": 0}
+24 -35
View File
@@ -1,10 +1,12 @@
from flask import request, jsonify
from app.api.helpers.response_helper import ResponseHelper
from app.api.services.base_service import BaseService
from marshmallow import ValidationError
from app.utils.logger import logger
from app.api.schemas.customer_consent import CustomerConsentSchema
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.api.enums import TransactionType
from app.extensions import db
class CustomerConsentService(BaseService):
@@ -22,50 +24,37 @@ class CustomerConsentService(BaseService):
dict: A standardized response.
"""
try:
with db.session.begin():
validated_data = CustomerConsentService.validate_data(data, CustomerConsentSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
validated_data = CustomerConsentService.validate_data(data, CustomerConsentSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
if(CustomerConsentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = CustomerConsentService.log_transaction(validated_data = validated_data)
if(CustomerConsentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = CustomerConsentService.log_transaction(validated_data = validated_data)
if not transaction:
logger.error(f"Failed to log transaction")
return ResponseHelper.error(result_description="Failed to log transaction.")
else:
return ResponseHelper.error(result_description="Invalid Customer or Account")
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
# Simulated processing logic
response_data = {
"resultCode": "00",
"resultDescription": "Request is received"
}
return response_data
db.session.commit()
return ResponseHelper.success(result_description="Request is received")
except ValidationError as err:
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({
"message": "Validation exception"
}) , 422
db.session.rollback()
return ResponseHelper.unprocessable_entity(result_description="Validation exception")
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
db.session.rollback()
return ResponseHelper.error(result_description=str(err))
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}) , 500
db.session.rollback()
return ResponseHelper.internal_server_error()
+158 -65
View File
@@ -1,10 +1,18 @@
from flask import session, jsonify
from app.models.loan import Loan
from app.utils.logger import logger
from app.api.services.base_service import BaseService
from app.api.schemas.eligibility_check import EligibilityCheckSchema
from marshmallow import ValidationError
from app.api.enums import TransactionType
from app.api.integrations import SimbrellaClient
from app.api.integrations import SimbrellaIntegration
from app.extensions import db
from app.models import Offer, RACCheck
from app.api.services.offer_analysis import OfferAnalysis
from app.api.helpers.response_helper import ResponseHelper
import random
class EligibilityCheckService(BaseService):
TRANSACTION_TYPE = TransactionType.ELIGIBILITY_CHECK
@@ -21,85 +29,170 @@ class EligibilityCheckService(BaseService):
dict: A standardized response.
"""
try:
with db.session.begin():
validated_data = EligibilityCheckService.validate_data(data, EligibilityCheckSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
validated_data = EligibilityCheckService.validate_data(data, EligibilityCheckSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
transactionId = validated_data.get('transactionId')
msisdn = validated_data.get('msisdn')
customer = EligibilityCheckService.get_or_create_customer(validated_data = validated_data)
customer = EligibilityCheckService.get_or_create_customer(validated_data = validated_data)
if (EligibilityCheckService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = EligibilityCheckService.log_transaction(validated_data = validated_data)
if (EligibilityCheckService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = EligibilityCheckService.log_transaction(validated_data = validated_data)
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
# Call RACCheck
response = SimbrellaClient.rac_check(
customer_id = customer_id,
account_id = account_id,
transaction_id = transaction.id,
)
if not transaction:
logger.error(f"Failed to log transaction")
return ResponseHelper.error(result_description="Failed to log transaction.")
else:
return ResponseHelper.error(result_description="Invalid Customer or Account")
db.session.flush()
if "error" in response or response.get("status") != 200:
return jsonify({"message": "RACCheck failed", "error": response.get("message", response)}), 400
# Determine Loan count
is_eligible = EligibilityCheckService.check_loan_limits(customer_id)
if not is_eligible:
return ResponseHelper.error(result_description="Max loan count reached")
# Call RACCheck
response = SimbrellaIntegration.rac_check(
customer_id = customer_id,
account_id = account_id,
transaction_id = transaction.transaction_id,
)
offers = [
{
"offerId": "Offer1",
"productId": "Product1",
"minAamount": 100,
"maxAamount": 1000,
"tenor": 12
},
{
"offerId": "Offer2",
"productId": "Product2",
"minAamount": 200,
"maxAamount": 2000,
"tenor": 24
}
]
# this chek for error is not valid
if response.status_code != 200:
return ResponseHelper.error(result_description="RACCheck failed")
response = response.json()
# Simulate processing
response_data = {
"customerId": "CN621868",
"transactionId": "TX12345",
"countryCode": "NG",
"msisdn": "3451342",
"eligibleOffers": offers,
"resultDescription": "Successful",
"resultCode": "00",
"accountId": "ACN8263457"
}
logger.info(f"This is Response (from Eligibility Check): {str(response)}", exc_info=True)
return response_data
if not response or response['responseCode'] != '00':
if response:
logger.error(f"{response['responseMessage']}")
return ResponseHelper.error(result_description=f"RACCheck failed")
rack_checks_response = response['data']['racResponse']
rac_check = RACCheck.add_rac_check(
customer_id = customer_id,
account_id = account_id,
transaction_id = transaction.transaction_id,
data = rack_checks_response
)
if not rac_check:
logger.error(f"Failed to save RACCheck")
return ResponseHelper.error(result_description="Failed to save RACCheck.")
# -----------------TIME FOR ANALYSIS TO REGISTER OFFER ----------------------
# eligible_offers = []
try:
eligible_offers = OfferAnalysis.decide_offer(
transaction_id=transactionId,
rac_check=rac_check,
validated_data=validated_data,
customer_id=customer_id,
rack_checks_response =rack_checks_response
)
except ValueError as ve:
logger.error(str(ve))
return ResponseHelper.error(result_description= str(ve))
# -----------------------------------------------------------------------
# s = Offer.get_all_offers()
# eligible_offers = []
# for offer in offers:
# # Determine an approved amount
# random_float = random.random() # temporary to play data
# approved_amount = min(offer.max_amount, offer.max_amount * random_float) #temporary for now
# approved_amount = round(approved_amount, 2)
#
# transaction_offer = TransactionOffer.create_transaction_offer(
# customer_id = customer.id,
# transaction_id = transaction.transaction_id,
# offer_id = offer.id,
# min_amount = offer.min_amount,
# max_amount = offer.max_amount,
# eligible_amount = approved_amount,
# product_id = offer.product_id,
# tenor = offer.tenor
# )
#
# # Visible offer ID: offer_id + padded(transaction_offer.id)
# padded_id = str(transaction_offer.id).zfill(6)
# public_offer_id = f"{offer.id}{padded_id}"
#
# eligible_offers.append({
# "offerId": public_offer_id,
# "product_id": offer.product_id,
# "min_amount": offer.min_amount,
# "max_amount": approved_amount,
# "tenor": offer.tenor
# })
# Simulate processing
response_data = {
"customerId": customer_id,
"transactionId": transactionId,
"countryCode": "NG",
"msisdn": msisdn,
"eligibleOffers": eligible_offers,
"accountId": account_id
}
return ResponseHelper.success(data=response_data)
except ValidationError as err:
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({
"message": "Validation exception"
}) , 422
db.session.rollback()
return ResponseHelper.unprocessable_entity(result_description="Validation exception")
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
db.session.rollback()
return ResponseHelper.error(result_description=str(err))
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}) , 500
db.session.rollback()
return ResponseHelper.internal_server_error()
@staticmethod
def check_loan_limits(customer_id):
"""
Checks if a customer has exceeded the loan limits for given offer.
"""
loan = Loan.get_customer_last_loan(customer_id)
if not loan:
return True
offer_id = loan.offer_id[:5]
offer = Offer.get_offer_by_id(offer_id)
if not offer:
logger.error(f"Offer not found for offer_id: {offer_id} (customer_id: {customer_id})")
return False
daily_count = Loan.get_daily_loan_count(customer_id, offer.product_id)
logger.info(f"daily_count: {daily_count}, Max: {offer.max_daily_loans}")
if offer.max_daily_loans is not None and daily_count >= offer.max_daily_loans:
return False
return True
+44 -50
View File
@@ -1,9 +1,13 @@
from flask import request, jsonify
from marshmallow import ValidationError
from app.api.enums.loan_status import LoanStatus
from app.models import Customer
from app.utils.logger import logger
from app.api.schemas.loan_status import LoanStatusSchema
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.api.enums import TransactionType
from app.extensions import db
from app.api.helpers.response_helper import ResponseHelper
class LoanStatusService(BaseService):
@@ -21,67 +25,57 @@ class LoanStatusService(BaseService):
dict: A standardized response.
"""
try:
validated_data = LoanStatusService.validate_data(data, LoanStatusSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
with db.session.begin():
# Validate data
validated_data = LoanStatusService.validate_data(data, LoanStatusSchema())
if (LoanStatusService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = LoanStatusService.log_transaction(validated_data = validated_data)
customer_id = validated_data.get('customerId')
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
logger.info(f"Looking for customer *** {customer_id}")
customer = Customer.get_customer_with_loan_list(customer_id)
transactionId = validated_data.get('transactionId')
account_id = validated_data.get('accountId')
loans = [
{
"debtId": "123456789",
"loanDate": "2019-10-18 14:26:21.063",
"dueDate": "2019-11-20 14:26:21.063",
"currentLoanAmount": 8500,
"initialLoanAmount": 10000,
"defaultPenaltyFee": 0,
"continuousFee": 0,
"productId": "101"
if(LoanStatusService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
# Get loans
loans = [loan.to_dict() for loan in customer.loans if loan.status == LoanStatus.ACTIVE]
transaction = LoanStatusService.log_transaction(validated_data = validated_data)
if not transaction:
logger.error(f"Failed to log transaction")
return ResponseHelper.error(result_description="Failed to log transaction.")
else:
return ResponseHelper.error(result_description="Invalid Customer or Account")
total_debt_amount = sum(
loan.get("currentLoanAmount") or 0
for loan in loans
)
# Simulated processing logic
response_data = {
"customerId": customer_id,
"accountId": account_id,
"transactionId": transactionId,
"loans": loans,
"totalDebtAmount": total_debt_amount,
}
]
# Simulated processing logic
response_data = {
"customerId": "CN621868",
"transactionId": "Tr201712RK9232P115",
"loans": loans,
"totalDebtAmount": 8500,
"resultCode": "00",
"resultDescription": "Successful"
}
return response_data
db.session.commit()
return ResponseHelper.success(data=response_data)
except ValidationError as err:
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({
"message": "Validation exception"
}) , 422
db.session.rollback()
return ResponseHelper.unprocessable_entity(result_description="Validation exception")
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
db.session.rollback()
return ResponseHelper.error(result_description=str(err))
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}) , 500
db.session.rollback()
return ResponseHelper.internal_server_error()
+10 -25
View File
@@ -3,7 +3,9 @@ from marshmallow import ValidationError
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.utils.logger import logger
from app.api.schemas.notification_callback import NotificationCallbackSchema
from app.api.schemas.notification_callback import NotificationCallbackSchema
from app.extensions import db
from app.api.helpers.response_helper import ResponseHelper
class NotificationCallbackService(BaseService):
TRANSACTION_TYPE = TransactionType.NOTIFICATION_CALLBACK
@@ -26,37 +28,20 @@ class NotificationCallbackService(BaseService):
schema = NotificationCallbackSchema()
validated_data = schema.load(data) # Raises ValidationError if invalid
# Simulated processing logic
response_data = {
"resultCode": "00",
"resultDescription": "Successful"
}
# return ResponseHelper.success(
# data=response_data,
# message="Notification callback processed successfully"
# )
return response_data
return ResponseHelper.success()
except ValidationError as err:
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({
"message": "Validation exception"
}) , 422
db.session.rollback()
return ResponseHelper.unprocessable_entity(result_description="Validation exception")
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
db.session.rollback()
return ResponseHelper.error(result_description=str(err))
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}) , 500
db.session.rollback()
return ResponseHelper.internal_server_error()
+250
View File
@@ -0,0 +1,250 @@
from decimal import Decimal
from app.models import Offer, TransactionOffer
from app.models.loan import Loan
import random
import logging
from app.config import Config
RAC_TRUE_CHECK_RULES = Config.rac_true_rules
RAC_FALSE_CHECK_RULES = Config.rac_false_rules
RAC_SALARY_PAYMENTS = Config.rac_salary_payments
logger = logging.getLogger(__name__)
class OfferAnalysis:
@staticmethod
def get_offer(transaction_id, rac_response, validated_data):
customer_id = validated_data.get("customerId")
product_id = validated_data.get("productId")
offer_id = validated_data.get("offerId")
transaction_offer_id = int(offer_id[5:]) # The last part is int
logger.info(f"customer_id == *************** : {customer_id}")
logger.info(f"product_id == *************** : {product_id}")
logger.info(f"offer_id == *************** : {offer_id}")
logger.info(f"transaction_offer_id == *************** : {transaction_offer_id}")
transaction_offer = TransactionOffer.is_valid_transaction_offer(transaction_offer_id, customer_id, product_id)
if not transaction_offer:
raise ValueError("Invalid Transaction Offer.")
eligible_amount = transaction_offer.eligible_amount
offer = Offer.is_valid_offer( transaction_offer.offer_id)
if not offer:
raise ValueError("Invalid Offer.")
original_transaction = transaction_id
return transaction_offer, offer, eligible_amount, original_transaction
@staticmethod
def _analyze_rack_checks(rack_response, offer):
logger.info(f"This is PayLoad for ANALYSYS ***** : {str(rack_response)}", exc_info=True)
logger.info(f"RACk TRUE RULES {str(RAC_TRUE_CHECK_RULES)}", exc_info=True)
logger.info(f"RACk FALSE RULES {str(RAC_FALSE_CHECK_RULES)}", exc_info=True)
logger.info(f"RACk SALARY PAYMENTS {str(RAC_SALARY_PAYMENTS)}", exc_info=True)
if not isinstance(rack_response, dict) or not offer :
raise ValueError("Invalid RAC response format.")
failed_true_rules = []
failed_false_rules = []
salaries = []
# Expects true
for rule in RAC_TRUE_CHECK_RULES:
if not rack_response.get(rule, False):
failed_true_rules.append(rule)
# Expects false
for rule in RAC_FALSE_CHECK_RULES:
if rack_response.get(rule, True):
failed_false_rules.append(rule)
# Salary rules
for key in RAC_SALARY_PAYMENTS:
value = rack_response.get(key)
if isinstance(value, Decimal):
# Only use values greater than 0
if value > 0:
salaries.append(value)
elif isinstance(value, (int, float, str)):
try:
value = Decimal(str(value))
if value > 0:
salaries.append(value)
except:
logger.warning(f"Could not convert value of {key} to Decimal: {value}")
if failed_true_rules or failed_false_rules or not salaries:
logger.warning(f"Failed TRUE rules: {failed_true_rules}")
logger.warning(f"Failed FALSE rules: {failed_false_rules}")
logger.warning("No salary records found in RAC response.")
raise ValueError(f"RAC analysis failed")
logger.info(f"These are the salarie amounts ***** : {str(salaries)}", exc_info=True)
#Least salary in the last 6 months
min_salary = min(salaries)
# Check consistency rule
consistent_income = rack_response.get("rule7_consistent_salary_amount", False)
# Determine percentage based on offer tenor
tenor = offer.tenor
if tenor == 30 and consistent_income:
eligible_amount = min_salary * Decimal("0.5")
logger.info("Applying 50% of least salary in 6 months due to 1-month offer tenor with stable income.")
elif tenor == 90 and consistent_income:
eligible_amount = min_salary * Decimal("0.75")
logger.info("Applying 75% of least salary in 6 months due to 3-months offer tenor with stable income.")
else: # Income is not consistent
eligible_amount = 0
logger.info("Applying np percentage on least salary due unstable income.")
logger.info(f"Calculated eligible amount from RAC: {eligible_amount} based on {'stable' if consistent_income else 'unstable'} income.")
return eligible_amount.quantize(Decimal("1.00"))
# "racResponse": {
# "accountStatus": true,
# "bvnValidated": true,
# "creditBureauCheck": false,
# "crmsCheck": true,
# "hasLien": false,
# "hasPastDueLoan": false,
# "hasSalaryAccount": true,
# "isWhitelisted": true,
# "noBouncedCheck": true
# },
#
'''
30 days
Eligibility amount (monthly SOL) - Adoption of 50% of the least salary inflow in the past 6 months
to determine loan eligibility for potential customers.
3 months
Adoption of 75% of the least salary inflow in the past 6 months to determine loan eligibility for
potential customers" for customers that have unstable income. 3 months
'''
# rac_true_rules
return 0
@staticmethod
def decide_offer(transaction_id, rac_check, validated_data, customer_id, rack_checks_response):
eligible_offers = []
# if we have active offers - we have to feed off it
logger.info(f"**RACK ANALYSIS** {customer_id}")
# Analyze Rack Checks
# new_eligible_amount = OfferAnalysis._analyze_rack_checks(rack_checks_response) #--> We need detail analysis
# we can now find the origin transactions
# Find the last loan - it will have original_transaction
last_customer_loan = Loan.get_customer_last_loan(customer_id)
# logger.info(f"{last_customer_loan}")
if last_customer_loan:
original_transaction = last_customer_loan.original_transaction or last_customer_loan.transaction_id
logger.info(f"transaction_id |-| original_transaction === > {transaction_id} {original_transaction}")
original_loan = Loan.get_customer_original_loan(customer_id, original_transaction)
if original_loan is not None:
logger.info(f"original_loan === > {original_loan}")
logger.info(f"loan_offer_id === > {original_loan.offer_id}")
original_offer_id = str(original_loan.offer_id[:5]) # The last part is str
transaction_offer_id = int(original_loan.offer_id[5:]) # The last part is int
original_transaction_offer = TransactionOffer.is_valid_transaction_offer(transaction_offer_id, customer_id, original_loan.product_id)
active_loans = Loan.get_active_loans_by_original_transaction(original_transaction)
sum_active_loans = sum(loan.current_loan_amount for loan in active_loans)
logger.info(f"sum_active_loans === > {sum_active_loans}")
real_eligible_amount = original_loan.eligible_amount - sum_active_loans
if real_eligible_amount < original_transaction_offer.min_amount:
logger.error(f"Max eligible amount ({real_eligible_amount}) is less than the minimum offer amount ({original_transaction_offer.min_amount}).")
raise ValueError("You are not eligible for a loan at this time.")
transaction_offer = TransactionOffer.create_transaction_offer(
customer_id=customer_id,
transaction_id=transaction_id,
original_transaction=original_transaction,
offer_id=original_offer_id,
min_amount=original_transaction_offer.min_amount,
max_amount=original_transaction_offer.max_amount,
eligible_amount=real_eligible_amount,
product_id=original_loan.product_id,
tenor=original_loan.tenor
)
# Visible offer ID: offer_id + padded(transaction_offer.id)
padded_id = str(transaction_offer.id).zfill(6)
public_offer_id = f"{original_offer_id}{padded_id}"
eligible_offers.append({
"offerId": public_offer_id,
"product_id": original_transaction_offer.product_id,
"min_amount": original_transaction_offer.min_amount,
"max_amount": round(real_eligible_amount, 2),
"tenor": original_loan.tenor
})
return eligible_offers
offers = Offer.get_all_offers()
for offer in offers:
new_eligible_amount = OfferAnalysis._analyze_rack_checks(rack_checks_response, offer)
approved_amount = new_eligible_amount
approved_amount = round(approved_amount, 2)
if approved_amount < offer.min_amount:
logger.error(f"Max eligible amount ({approved_amount}) is less than the minimum offer amount ({offer.min_amount}).")
raise ValueError("You are not eligible for a loan at this time.")
transaction_offer = TransactionOffer.create_transaction_offer(
customer_id=customer_id,
transaction_id=transaction_id,
original_transaction=transaction_id,
offer_id=offer.id,
min_amount=offer.min_amount,
max_amount=offer.max_amount,
eligible_amount=approved_amount,
product_id=offer.product_id,
tenor=offer.tenor
)
# Visible offer ID: offer_id + padded(transaction_offer.id)
padded_id = str(transaction_offer.id).zfill(6)
public_offer_id = f"{offer.id}{padded_id}"
eligible_offers.append({
"offerId": public_offer_id,
"product_id": offer.product_id,
"min_amount": offer.min_amount,
"max_amount": approved_amount,
"tenor": offer.tenor
})
return eligible_offers
+161 -38
View File
@@ -1,9 +1,21 @@
from flask import request, jsonify
from marshmallow import ValidationError
from app.api.integrations.kafka import KafkaIntegration
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.models.customer import Customer
from app.models.loan_charge import LoanCharge
from app.utils.logger import logger
from app.api.schemas.provide_loan import ProvideLoanSchema
from app.api.schemas.provide_loan import ProvideLoanSchema
from threading import Thread
from app.models import Loan, Offer, Charge , TransactionOffer, RACCheck
from app.api.enums import LoanStatus
from app.extensions import db
from datetime import datetime, timezone
from dateutil.relativedelta import relativedelta
from app.models import LoanRepaymentSchedule
from app.api.services.offer_analysis import OfferAnalysis
from app.api.helpers.response_helper import ResponseHelper
class ProvideLoanService(BaseService):
TRANSACTION_TYPE = TransactionType.PROVIDE_LOAN
@@ -21,55 +33,166 @@ class ProvideLoanService(BaseService):
dict: A standardized response.
"""
try:
validated_data = ProvideLoanService.validate_data(data, ProvideLoanSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
with db.session.begin():
validated_data = ProvideLoanService.validate_data(data, ProvideLoanSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
request_id = validated_data.get('requestId')
collection_type = validated_data.get('collectionType')
transaction_id = validated_data.get('transactionId')
offer_id = validated_data.get('offerId')
amount = validated_data.get("requestedAmount")
product_id = validated_data.get("productId")
channel = validated_data.get('channel')
if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = ProvideLoanService.log_transaction(validated_data = validated_data)
customer = Customer.is_valid_customer(customer_id)
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
response_data = {
"requestId": "202111170001371256908",
"transactionId": "Tr201712RK9232P115",
"customerId": "CN621868",
"accountId": "ACN8263457",
"msisdn": "3451342",
"resultCode": "00",
"resultDescription": "Successful"
}
if (ProvideLoanService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
rac_response = RACCheck.get_rac_check(customer_id = customer_id, account_id = account_id)
try:
transaction_offer, offer, eligible_amount, original_transaction = OfferAnalysis.get_offer(
transaction_id=transaction_id,
rac_response=rac_response,
validated_data=validated_data
)
except ValueError as ve:
logger.error(str(ve))
return ResponseHelper.error(result_description=str(ve))
if(amount < transaction_offer.min_amount):
return ResponseHelper.error(result_description="The amount is less than the minimum allowed transaction amount.")
elif amount > transaction_offer.max_amount:
return ResponseHelper.error(result_description="The amount is greater than the maximum allowed transaction amount.")
return response_data
# transaction_offer_id = int(offer_id[5:]) # The last part is int
# transaction_offer = TransactionOffer.is_valid_transaction_offer(transaction_offer_id)
# if not transaction_offer:
# logger.error(f"Invalid Transaction Offer")
# return jsonify({
# "message": "Invalid Transaction Offer."
# }), 400
# eligible_amount = transaction_offer.eligible_amount
# offer = Offer.is_valid_offer( transaction_offer.offer_id)
# if not offer:
# logger.error(f"Invalid Offer")
# return jsonify({
# "message": "Invalid Offer."
# }), 400
# Log Transaction
transaction = ProvideLoanService.log_transaction(validated_data=validated_data)
if not transaction:
logger.error(f"Failed to log transaction")
return ResponseHelper.error(result_description="Failed to log transaction.")
db.session.flush()
charges = ProvideLoanService.calculate_charges(offer, amount)
upfront_fee = charges["upfront_payment"]
repayment_amount = charges["repayment_amount"]
#installment_amount = charges["installment_amount"]
num_schedules = offer.schedule
upfront_payment = charges["upfront_payment"]
total_amount = charges["total_amount"]
installment_amount = charges["installment_amount"]
interest = charges["interest"]
management = charges["management"]
insurance = charges["insurance"]
vat = charges["vat"]
padded_id = str(transaction_id).zfill(12)
loan_ref = f"{padded_id}{channel}{offer.product_id}"
# Save the loan details
loan = Loan.create_loan(
customer_id = customer_id,
account_id = account_id,
offer_id = offer_id,
product_id = offer.product_id,
collection_type = collection_type,
transaction_id = validated_data.get('transactionId'),
original_transaction = transaction_offer.original_transaction,
initial_loan_amount = validated_data.get('requestedAmount'),
upfront_fee = upfront_fee,
repayment_amount = repayment_amount,
installment_amount = installment_amount,
eligible_amount=eligible_amount,
status = LoanStatus.ACTIVE,
tenor = offer.tenor,
reference = loan_ref
)
if not loan:
logger.error(f"Failed to save loan details")
return ResponseHelper.error(result_description="Failed to save loan details.")
db.session.flush()
current_product_id = offer.product_id
schedule = LoanRepaymentSchedule.add_repayment_schedule(loan = loan, num_schedules = num_schedules, transaction_id = transaction_id)
if not schedule:
logger.error(f"Failed to create repayment schedule for loan ID {loan.id}")
return ResponseHelper.error(result_description="Failed to generate loan repayment schedule.")
# charges = Charge.get_offer_charges(offer.id)
# logger.info(f"{charges}")
loan_id = loan.id
loan_charges = LoanCharge.create_charges_for_loan(loan_id = loan_id, transaction_id = transaction_id, referenced_amount = 800, charges = charges)
else:
return ResponseHelper.error(result_description="Invalid Customer or Account")
response_data = {
"requestId": request_id,
"transactionId": transaction_id,
"loanRef": loan_ref,
"customerId": customer_id,
"accountId": account_id,
"msisdn": customer.msisdn
}
# KafkaIntegration.send_loan_request(loan_data = response_data, request_id = request_id)
# Call Kafka in a background thread
thread = Thread(target=ProvideLoanService.async_send_to_kafka, args=(response_data, request_id, "PROCESS_PAYMENT"))
thread.start()
db.session.commit()
return ResponseHelper.success(data=response_data)
except ValidationError as err:
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({
"message": "Validation exception"
}) , 422
db.session.rollback()
return ResponseHelper.unprocessable_entity(result_description="Validation exception")
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
db.session.rollback()
return ResponseHelper.error(result_description=str(err))
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}) , 500
db.session.rollback()
return ResponseHelper.internal_server_error()
+63 -39
View File
@@ -1,9 +1,16 @@
from flask import request, jsonify
from marshmallow import ValidationError
from app.api.enums.loan_status import LoanStatus
from app.api.helpers.response_helper import ResponseHelper
from app.models import Repayment
from app.models.customer import Customer
from app.models.loan import Loan
from app.utils.logger import logger
from app.api.schemas.repayment import RepaymentSchema
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.api.enums import TransactionType
from threading import Thread
from app.extensions import db
class RepaymentService(BaseService):
TRANSACTION_TYPE = TransactionType.REPAYMENT
@@ -20,56 +27,73 @@ class RepaymentService(BaseService):
dict: A standardized response.
"""
try:
validated_data = RepaymentService.validate_data(data, RepaymentSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
with db.session.begin():
validated_data = RepaymentService.validate_data(data, RepaymentSchema())
if (RepaymentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = RepaymentService.log_transaction(validated_data = validated_data)
customer_id = validated_data.get('customerId')
request_id = validated_data.get('requestId')
loan_id = validated_data.get('debtId')
account_id = validated_data.get('accountId')
loan_ref = validated_data.get('loanRef')
# customer = Customer.get_customer_with_loan_list(customer_id)
transaction_id = validated_data.get('transactionId')
initiated_by = validated_data.get('initiatedBy')
logger.error(f"HERE 0002a **** ")
if(RepaymentService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
logger.error(f"HERE 0001a **** ")
# Check loan exists
loan = Loan.get_customer_loan(loan_id = loan_id, customer_id = customer_id)
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
# Save the repayment details
repayment = Repayment.create_repayment(
customer_id = customer_id,
loan = loan,
transaction_id = transaction_id
)
# Simulated processing logic
response_data = {
"customerId": "CN621868",
"productId": "101",
"debtId": "273194670",
"resultCode": "00",
"resultDescription": "Successful"
}
if not repayment:
logger.error(f"Failed to save repayment details")
return ResponseHelper.error(result_description="Failed to save repayment details.")
# return ResponseHelper.success(
# data=response_data,
# message="Repayment processed successfully"
# )
#Update Loan status
Loan.update_status(loan_id = loan_id, status = LoanStatus.START_REPAY) # repay started bu user
transaction = RepaymentService.log_transaction(validated_data = validated_data)
return response_data
if not transaction:
logger.error(f"Failed to log transaction")
return ResponseHelper.error(result_description="Failed to log transaction.")
else:
logger.error(f"Invalid Customer or AccountID {account_id} to CustomerID{customer_id} ")
return ResponseHelper.error(result_description="Invalid Customer or Account")
# Simulated processing logic
response_data = {
"transactionId": transaction_id,
"customerId": customer_id,
"productId": loan.product_id,
"loanRef": loan_ref,
"debtId": loan_id
}
# Call Kafka in a background thread
thread = Thread(target=RepaymentService.async_send_to_kafka, args=(response_data, request_id, "LOAN_REPAYMENT"))
thread.start()
db.session.commit()
return ResponseHelper.success(data=response_data)
except ValidationError as err:
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({
"message": "Validation exception"
}) , 422
db.session.rollback()
return ResponseHelper.unprocessable_entity(result_description="Validation exception")
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
db.session.rollback()
return ResponseHelper.error(result_description=str(err))
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}) , 500
db.session.rollback()
return ResponseHelper.internal_server_error()
+124 -56
View File
@@ -1,12 +1,17 @@
from flask import request, jsonify
from marshmallow import ValidationError
from app.api.helpers.response_helper import ResponseHelper
from app.api.services.base_service import BaseService
from app.api.enums import TransactionType
from app.api.enums import TransactionType
from app.utils.logger import logger
from app.api.schemas.select_offer import SelectOfferSchema
from app.api.schemas.select_offer import SelectOfferSchema
from app.extensions import db
from app.models import Offer
from datetime import date
from dateutil.relativedelta import relativedelta
class SelectOfferService(BaseService):
TRANSACTION_TYPE = TransactionType.SELECT_OFFER
TRANSACTION_TYPE = TransactionType.SELECT_OFFER
@staticmethod
def process_request(data):
@@ -20,74 +25,137 @@ class SelectOfferService(BaseService):
dict: A standardized response.
"""
try:
validated_data = SelectOfferService.validate_data(data, SelectOfferSchema())
account_id = validated_data.get('accountId')
customer_id = validated_data.get('customerId')
with db.session.begin():
validated_data = SelectOfferService.validate_data(
data, SelectOfferSchema()
)
account_id = validated_data.get("accountId")
customer_id = validated_data.get("customerId")
amount = validated_data.get("requestedAmount")
product_id = validated_data.get("productId")
transaction_offer_id = validated_data.get("offerId")
transaction_id = validated_data.get("transactionId")
request_id = validated_data.get("requestId")
if (SelectOfferService.validate_account_ownership(account_id = account_id, customer_id = customer_id)):
transaction = SelectOfferService.log_transaction(validated_data = validated_data)
offer_id = int(transaction_offer_id[5:]) # The last part is int
if not transaction:
logger.error(f"Failed to log transaction")
return jsonify({
"message": "Failed to log transaction."
}), 400
else:
return jsonify({
"message": "Invalid Customer or Account"
}), 400
offers = [
#"offerId": "SAL30001129",
if SelectOfferService.validate_account_ownership(
account_id=account_id, customer_id=customer_id
):
transaction = SelectOfferService.log_transaction(
validated_data=validated_data
)
if not transaction:
logger.error(f"Failed to log transaction")
return ResponseHelper.error(result_description="Failed to log transaction.")
else:
return ResponseHelper.error(result_description="Invalid Customer or Account")
# Get the offer by product ID
offer = Offer.get_offer_by_product_id(product_id)
db.session.flush()
if amount < offer.min_amount:
logger.error(f"The amount {amount} is less than the minimum allowed offer amount {offer.min_amount}.")
return ResponseHelper.error(result_description="The amount is less than the minimum allowed offer amount.")
elif amount > offer.max_amount:
logger.error(f"The amount {amount} is greater than the minimum allowed offer amount {offer.min_amount}.")
return ResponseHelper.error(result_description="The amount is greater than the maximum allowed offer amount.")
charges = SelectOfferService.calculate_charges(offer, amount)
upfront_payment = charges["upfront_payment"]
total_amount = charges["total_amount"]
installment_amount = charges["installment_amount"]
interest = charges["interest"]
management = charges["management"]
insurance = charges["insurance"]
vat = charges["vat"]
repayment_amount = charges["repayment_amount"]
interest_amount = charges["interest_amount"]
# Calculate the repayment dates
tenor = offer.tenor
start_date = date.today()
# Convert tenor to months
months = offer.schedule # tenor // 30
recommended_repayment_dates = [
(start_date + relativedelta(months=i + 1)).isoformat()
for i in range(months)
]
offers = [
{
"offerId": "14451",
"productId": "2030",
"amount": 10000.0,
"upfrontPayment": 1000.0,
"interestRate": 3.0,
"managementRate": 1.0,
"managementFee": 1.0,
"insuranceRate": 1.0,
"insuranceFee": 100.0,
"VATRate": 7.5,
"VATAmount": 100.0,
"recommendedRepaymentDates": ["2022-11-30"],
"installmentAmount": 11000.0,
"totalRepaymentAmount": 11000.0
"offerId": transaction_offer_id,
"productId": product_id,
"amount": amount,
"upfrontPayment": upfront_payment,
"interestRate": offer.interest_rate,
"interestFee": interest_amount,
"managementRate": offer.management_rate,
"managementFee": management["fee"],
"insuranceRate": offer.insurance_rate,
"insuranceFee": insurance["fee"],
"VATRate": offer.vat_rate,
"VATAmount": vat["fee"],
"recommendedRepaymentDates": recommended_repayment_dates,
"repaymentAmount": repayment_amount,
"installmentAmount": installment_amount,
"totalRepaymentAmount": total_amount,
}
]
# Business logic - selecting an offer
response_data = {
"outstandingDebtAmount": 0,
"requestId": "202111170001371256908",
"transactionId": "1231231321232",
"customerId": "1256907",
"accountId": "5948306019",
"loan": offers,
"resultCode": "00",
"resultDescription": "Successful"
# "offerId": offer.id,
# "productId": product_id,
# "amount": amount,
# "upfrontPayment": upfront_payment,
# "interestRate": interest["rate"],
# "managementRate": management["rate"],
# "managementFee": management["fee"],
# "insuranceRate": insurance["rate"],
# "insuranceFee": insurance["fee"],
# "VATRate": vat["rate"],
# "VATAmount": vat["fee"],
# "recommendedRepaymentDates": recommended_repayment_dates,
# "installmentAmount": installment_amount,
# "totalRepaymentAmount": total_amount,
#
# Business logic - selecting an offer
response_data = {
"outstandingDebtAmount": 0,
"requestId": request_id,
"transactionId": transaction_id,
"customerId": customer_id,
"accountId": account_id,
"loan": offers,
}
return response_data
db.session.commit()
return ResponseHelper.success(data=response_data)
except ValidationError as err:
logger.error(f"Validation Error: {getattr(err, 'messages', str(err))}")
return jsonify({
"message": "Validation exception"
}) , 422
db.session.rollback()
return ResponseHelper.unprocessable_entity(result_description="Validation exception")
except ValueError as err:
logger.error(f"{getattr(err, 'messages', str(err))}")
return jsonify({
"message": str(err)
}) , 400
db.session.rollback()
return ResponseHelper.error(result_description=str(err))
except Exception as e:
logger.error(f"An error occurred: {str(e)}", exc_info=True)
return jsonify({
"message": "Internal Server Error"
}) , 500
db.session.rollback()
return ResponseHelper.internal_server_error()
+55 -4
View File
@@ -1,7 +1,6 @@
import os
from datetime import timedelta
class Config:
"""Base configuration for Flask app"""
@@ -9,20 +8,20 @@ class Config:
API_URL = os.getenv("API_URL", "/swagger.json")
DEBUG = True
VALID_APP_ID = os.getenv("VALID_APP_ID", "app1")
VALID_API_KEY = os.getenv("VALID_API_KEY", "test-api-key-12345")
BASIC_AUTH_USERNAME = os.environ.get("BASIC_AUTH_USERNAME", "user")
BASIC_AUTH_PASSWORD = os.environ.get("BASIC_AUTH_PASSWORD", "password")
# Database Configuration
DATABASE_USER = os.environ.get("DATABASE_USER")
DATABASE_PASSWORD = os.environ.get("DATABASE_PASSWORD")
DATABASE_HOST = os.environ.get("DATABASE_HOST")
DATABASE_PORT = os.environ.get("DATABASE_PORT", 10532)
DATABASE_NAME = os.environ.get("DATABASE_NAME")
# Database Connection
SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{DATABASE_USER}:{DATABASE_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{DATABASE_NAME}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
SIMBRELLA_BASE_URL = os.getenv("SIMBRELLA_BASE_URL", "http://127.0.0.1:6337")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "secret-key")
JWT_ACCESS_TOKEN_EXPIRES = os.getenv("JWT_ACCESS_TOKEN_EXPIRES", timedelta(hours=1))
@@ -30,5 +29,57 @@ class Config:
"JWT_REFRESH_TOKEN_EXPIRES", timedelta(days=30)
)
# KAFKA_BROKER = 'dev-events.simbrellang.net:9085'
KAFKA_BROKER = os.getenv("KAFKA_BROKER", "dev-events.simbrellang.net:9085")
# SIMBRELLA_ENDPOINT_RAC_CHECKS = os.getenv("SIMBRELLA_ENDPOINT_RAC_CHECKS", "RACCheck")
VALID_APP_ID = os.getenv("SIMBRELLA_APP_ID", "app1")
VALID_API_KEY = os.getenv("SIMBRELLA_API_KEY", "test-api-key-12345")
SIMBRELLA_BASE_URL = os.getenv("SIMBRELLA_BASE_URL", "http://127.0.0.1:6337")
SIMBRELLA_ENDPOINT_RAC_CHECKS = os.getenv("SIMBRELLA_ENDPOINT_RAC_CHECKS","api/rac-check")
RAC_RESULT_accountStatus = os.environ.get("RAC_RESULT_accountStatus", "true")
RAC_RESULT_bvnValidated = os.environ.get("RAC_RESULT_bvnValidated", "true")
RAC_RESULT_creditBureauCheck = os.environ.get("RAC_RESULT_creditBureauCheck", "false")
RAC_RESULT_crmsCheck = os.environ.get("RAC_RESULT_crmsCheck", "true")
RAC_RESULT_hasLien = os.environ.get("RAC_RESULT_hasLien", "false")
RAC_RESULT_hasPastDueLoan = os.environ.get("RAC_RESULT_hasPastDueLoan", "false")
RAC_RESULT_hasSalaryAccount = os.environ.get("RAC_RESULT_hasSalaryAccount", "true")
RAC_RESULT_isWhitelisted = os.environ.get("RAC_RESULT_isWhitelisted", "true")
RAC_RESULT_noBouncedCheck = os.environ.get("RAC_RESULT_noBouncedCheck", "true")
rac_true_rules = [
"rule1_45day_sal",
"rule2_2m_sal",
"rule3_no_bounced_check",
"rule4_current_loan_payments",
"rule5_no_past_due_fadv_loan",
"rule6_no_past_due_other_loan",
"rule7_consistent_salary_amount",
"rule8_whitelisted",
"rule9_regular_account",
"rule10_bvn_validation",
"rule11_CRC_no_delinquency",
"rule12_CRMS_no_delinquency",
"rule13_BVN_ignore",
"rule14_no_lien",
"rule15_null_ignore"
]
rac_false_rules = [
]
rac_salary_payments = [
"salarypaymenT_1",
"salarypaymenT_2",
"salarypaymenT_3",
"salarypaymenT_4",
"salarypaymenT_5",
"salarypaymenT_6"
]
settings = Config()
+5 -5
View File
@@ -5,20 +5,20 @@ from app.api.helpers.response_helper import ResponseHelper
def register_error_handlers(app):
@app.errorhandler(HTTPException)
def handle_http_exception(e):
return jsonify({'error': e.description}), e.code
return ResponseHelper.error(result_description=e.description, result_code=e.code )
@app.errorhandler(405)
def method_not_allowed(error):
return jsonify({"message": "Method Not Allowed"}), 405
return ResponseHelper.method_not_allowed()
@app.errorhandler(404)
def not_found(error):
return jsonify({"message": "Resource not found"}), 404
return ResponseHelper.not_found()
@app.errorhandler(400)
def bad_request(error):
return jsonify({"message": "Bad Request"}), 400
return ResponseHelper.bad_request()
@app.errorhandler(415)
def unsupported_media_type(error):
return jsonify({"message": "Unsupported Media Type"}), 415
return ResponseHelper.error(result_description="Unsupported Media Type", result_code="415")
+10 -1
View File
@@ -2,5 +2,14 @@ from .customer import Customer
from .account import Account
from .loan import Loan
from .transaction import Transaction
from .repayment import Repayment
from .loan_charge import LoanCharge
from .offer import Offer
from .charge import Charge
from .rac_checks import RACCheck
from .loan_repayment_schedule import LoanRepaymentSchedule
from .transaction_offers import TransactionOffer
from .repayments_data import RepaymentsData
__all__ = ['Customer', 'Account', 'Loan', 'Transaction']
__all__ = ['Customer', 'Account', 'Loan', 'Transaction', 'Repayment', 'LoanCharge', 'Offer', 'Charge', 'RACCheck', 'LoanRepaymentSchedule', 'TransactionOffer', 'RepaymentsData']
+21 -6
View File
@@ -1,5 +1,8 @@
from datetime import datetime, timezone
from sqlalchemy.orm import relationship
from app.extensions import db
from sqlalchemy.exc import IntegrityError
from sqlalchemy.sql import func
class Account(db.Model):
__tablename__ = 'accounts'
@@ -9,18 +12,30 @@ class Account(db.Model):
account_type = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
lien_amount = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
customer = relationship(
"Customer",
primaryjoin="Customer.id == Account.customer_id",
foreign_keys=[customer_id],
back_populates="accounts",
)
@classmethod
def create_account(cls, id, customer_id, account_type, status='active'):
account = cls(
id=id,
customer_id=customer_id,
account_type=account_type
account_type=account_type,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.session.add(account)
db.session.commit()
try:
db.session.add(account)
except IntegrityError as err:
raise ValueError(f"Database integrity error: {err}")
return account
@classmethod
@@ -30,7 +45,7 @@ class Account(db.Model):
return False
if account.lien_amount > 0:
return False
return True
return account
def __repr__(self):
return f'<Account {self.id}>'
+97
View File
@@ -0,0 +1,97 @@
from datetime import datetime, timezone, timedelta
from app.extensions import db
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
class Charge(db.Model):
__tablename__ = 'charges'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
offer_id = db.Column(db.String(50), nullable=False)
code = db.Column(db.String(50), nullable=False)
percent = db.Column(db.Float, default=0.0)
description = db.Column(db.Text, nullable=True)
due = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
offer = relationship(
"Offer",
primaryjoin="Charge.offer_id == Offer.id",
foreign_keys=[offer_id],
back_populates="charges",
)
@classmethod
def add_charges(cls, offer_id, charges):
"""
Add charges to an offer.
Args:
offer_id (int): ID of the offer to associate charges with.
charges (list): A list of dictionaries with keys:
code (str), amount (float), percent (float), description (str), due (int)
"""
if not charges or not isinstance(charges, list):
raise ValueError("Charges must be a non-empty list of dictionaries")
if offer_id is None:
raise ValueError("offer_id cannot be None")
offer_charges = []
for charge in charges:
code = charge.get("code")
percent = charge.get("percent", 0.0)
description = charge.get("description", "")
due_days = charge.get("due", 0)
existing = cls.query.filter_by(offer_id=offer_id, code=code).first()
if existing:
continue
charge_obj = cls(
offer_id = offer_id,
code = code,
percent = percent,
description = description,
due = due_days,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.session.add(charge_obj)
offer_charges.append(charge_obj)
return offer_charges
@classmethod
def get_offer_charges(cls, offer_id):
"""
Get all charges for a particular offer as a dictionary
Args:
offer_id (str): The offer ID.
"""
if not offer_id:
raise ValueError("offer_id not found")
charges = cls.query.filter_by(offer_id=offer_id).all()
return charges
def to_dict(self):
return {
'id': self.id,
'offerId': self.offer_id,
'code': self.code,
'percent': self.percent,
'description': self.description,
'due': self.due
}
def __repr__(self):
return f"<Charge {self.id} - Offer {self.offer_id} - {self.code}>"
+65 -16
View File
@@ -1,6 +1,12 @@
from datetime import datetime, timezone
from sqlalchemy.orm import relationship
#
# from app.api.services.offer_analysis import logger
from app.extensions import db
from app.models.account import Account
from sqlalchemy.exc import IntegrityError
from sqlalchemy.sql import func
# from app.utils.logger import logger
class Customer(db.Model):
__tablename__ = 'customers'
@@ -8,33 +14,76 @@ class Customer(db.Model):
id = db.Column(db.String(50), primary_key=True)
msisdn = db.Column(db.String(20), unique=True, nullable=False)
country_code = db.Column(db.String(3), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
accounts = relationship(
"Account",
primaryjoin="Customer.id == Account.customer_id",
foreign_keys="Account.customer_id",
back_populates="customer",
)
loans = relationship(
"Loan",
primaryjoin="Customer.id == Loan.customer_id",
foreign_keys="Loan.customer_id",
back_populates="customer",
)
transaction_offers = relationship(
"TransactionOffer",
primaryjoin="Customer.id == TransactionOffer.customer_id",
foreign_keys="TransactionOffer.customer_id",
back_populates="customer",
)
@classmethod
def is_eligible(cls, customer_id):
def is_valid_customer(cls, customer_id):
customer = cls.query.filter_by(id=customer_id).first()
if not customer:
return False, "Customer not found"
return True, "Customer is eligible"
return False
return customer
@classmethod
def create_customer(cls, id, msisdn, country_code, account_id, account_type='savings'):
if cls.query.filter_by(id=id).first():
raise ValueError("Customer already exists")
elif Account.query.filter_by(id=account_id).first():
raise ValueError("Account already exists")
elif cls.query.filter_by(msisdn=msisdn).first():
raise ValueError("msisdn already exists")
# Create the customer
customer = cls(id=id, msisdn=msisdn, country_code=country_code)
db.session.add(customer)
# Create an associated account
account = Account.create_account(
id=account_id,
customer_id=id,
account_type=account_type
)
db.session.commit()
customer = cls(
id=id,
msisdn=msisdn,
country_code=country_code,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
try:
db.session.add(customer)
# Create an associated account
account = Account.create_account(
id=account_id,
customer_id=id,
account_type=account_type
)
except IntegrityError as err:
raise ValueError(f"Database integrity error: {err}")
return customer
@classmethod
def get_customer_with_loan_list(cls, customer_id):
"""
Get customer by ID.
"""
customer = cls.query.filter_by(id=customer_id).first()
if not customer:
raise ValueError(f"Customer does not exist")
return customer
def __repr__(self):
+238 -8
View File
@@ -1,19 +1,132 @@
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from itertools import product
from app.extensions import db
from app.models.customer import Customer
from app.models.account import Account
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import relationship
from dateutil.relativedelta import relativedelta
from datetime import timedelta
import logging
from sqlalchemy import and_, or_, not_
from sqlalchemy.sql import func
logger = logging.getLogger(__name__)
class Loan(db.Model):
__tablename__ = 'loans'
id = db.Column(db.String(50), primary_key=True)
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
customer_id = db.Column(db.String(50), nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
original_transaction = db.Column(db.String(50), nullable=True)
account_id = db.Column(db.String(50), nullable=False)
product_id = db.Column(db.String(20), nullable=False)
principal_amount = db.Column(db.Float, nullable=False)
offer_id = db.Column(db.String(20), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
collection_type = db.Column(db.String(20), nullable=True)
current_loan_amount = db.Column(db.Float, nullable=True)
initial_loan_amount = db.Column(db.Float, nullable=False)
default_penalty_fee = db.Column(db.Float, default=0)
continuous_fee = db.Column(db.Float, default=0)
upfront_fee = db.Column(db.Float, nullable=True, default=0.0)
repayment_amount = db.Column(db.Float, nullable=True, default=0.0)
installment_amount = db.Column(db.Float, nullable=True, default=0.0)
status = db.Column(db.String(20), default='pending')
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
tenor = db.Column(db.Integer, nullable=True)
due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
eligible_amount = db.Column(db.Float, nullable=True, default=0.0)
disburse_date = db.Column(db.DateTime, nullable=True)
disburse_verify = db.Column(db.DateTime, nullable=True)
reference = db.Column(db.String(50), nullable=True)
disburse_result = db.Column(db.String(10), nullable=True)
disburse_description = db.Column(db.String(100), nullable=True)
verify_result = db.Column(db.String(10), nullable=True)
verify_description = db.Column(db.String(100), nullable=True)
customer = relationship(
"Customer",
primaryjoin="Customer.id == Loan.customer_id",
foreign_keys=[customer_id],
back_populates="loans",
)
loan_charges = relationship(
"LoanCharge",
primaryjoin="LoanCharge.loan_id == Loan.id",
foreign_keys="LoanCharge.loan_id",
back_populates="loan",
)
loan_repayment_schedules = relationship(
"LoanRepaymentSchedule",
primaryjoin="LoanRepaymentSchedule.loan_id == Loan.id",
foreign_keys="LoanRepaymentSchedule.loan_id",
back_populates="loan",
)
@classmethod
def create_loan(
cls,
customer_id,
account_id,
offer_id,
product_id,
initial_loan_amount,
collection_type,
transaction_id,
original_transaction,
upfront_fee,
repayment_amount,
installment_amount,
tenor,
eligible_amount,
reference,
status = "pending",
):
# Check if customer exists
customer = Customer.is_valid_customer(customer_id)
if not customer:
raise ValueError("Customer does not exist")
now = datetime.now(timezone.utc)
due_date = now + timedelta(days=tenor)
# Create and save the loan
loan = cls(
customer_id = customer_id,
account_id = account_id,
offer_id = offer_id,
product_id = product_id,
collection_type = collection_type,
transaction_id = transaction_id,
original_transaction = original_transaction,
initial_loan_amount = initial_loan_amount,
current_loan_amount = initial_loan_amount,
upfront_fee = upfront_fee,
repayment_amount = repayment_amount,
installment_amount = installment_amount,
due_date=due_date,
tenor = tenor,
status = status,
eligible_amount =eligible_amount,
reference = reference,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
try:
db.session.add(loan)
except IntegrityError as err:
raise ValueError(f"Database integrity error: {err}")
return loan
@classmethod
def has_active_loans(cls, customer_id):
@@ -23,9 +136,126 @@ class Loan(db.Model):
).count()
if active_loans > 0:
return False, "Customer has active loans"
return True, "No active loans"
return False
return True
@classmethod
def get_customer_loan(cls, loan_id, customer_id):
"""
Get customer's active loans by loan_id.
"""
loan = cls.query.filter_by(id = loan_id, customer_id = customer_id).first()
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist or does not belong to customer {customer_id}.")
return loan
@classmethod
def get_customer_original_loan(cls, customer_id, original_transaction):
"""
Get customer's original loan offer.
"""
original_loan = cls.query.filter(and_( cls.customer_id ==customer_id, cls.original_transaction==original_transaction, cls.transaction_id==original_transaction )).first()
if not original_loan:
return None
logger.info(f" get_customer_original_loan ==>>>> {original_loan}")
return original_loan
@classmethod
def get_customer_last_loan(cls, customer_id):
"""
Get customer's active loans.
"""
logger.info(f"get_customer_last_loan [customer_id] ==>>>> {customer_id}")
# loan = cls.query.filter_by( cls.customer_id == customer_id).first()
loan = cls.query.filter(and_( cls.customer_id ==customer_id, cls.status=='active')).first()
if not loan:
return None
# loan = {
# "original_transaction":"",
# "eligible_amount": 0,
# "loan_amount": 0,
# "customer_id": customer_id,
# "transaction_id": "",
# "resultDescription": "No Active Loan"
# }
logger.info(f" get_customer_last_loan ==>>>> {loan}")
return loan
@classmethod
def get_active_loans_by_original_transaction(cls, original_transaction_id):
"""
Get all active loans with the same original_transaction ID.
"""
active_loans = cls.query.filter_by(
original_transaction=original_transaction_id,
# status='active'
).all()
return active_loans
@classmethod
def update_status(cls, loan_id, status):
"""
Update the status of the loan with the given loan_id.
"""
# Retrieve loan
loan = cls.query.get(loan_id)
if not loan:
raise ValueError(f"Loan with ID {loan_id} does not exist.")
if loan.status == status:
return
# Update loan status and the updated_at timestamp
loan.status = status
@classmethod
def get_daily_loan_count(cls, customer_id, product_id):
"""
Returns the count of loans created today for a customer.
"""
start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
end_of_day = start_of_day + timedelta(days=1)
return cls.query.filter_by(
customer_id=customer_id,
product_id=product_id,
).filter(
cls.created_at >= start_of_day,
cls.created_at < end_of_day
).count()
def to_dict(self):
"""
Convert the Loan object to a dictionary format for JSON serialization.
"""
return {
'debtId': self.id,
'transactionId': self.transaction_id,
'loanRef': self.reference,
'productId': self.product_id,
'initialLoanAmount': self.initial_loan_amount,
'currentLoanAmount': self.current_loan_amount,
'defaultPenaltyFee': self.default_penalty_fee,
'continuousFee': self.continuous_fee,
'collectionType': self.collection_type,
'upfrontFee': self.upfront_fee,
'repaymentAmount': self.repayment_amount,
'installmentAmount': self.installment_amount,
'status': self.status,
'tenor': self.tenor,
'dueDate': self.due_date.isoformat() if self.due_date else None,
'loanDate': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<Loan {self.id}>'
+91
View File
@@ -0,0 +1,91 @@
from datetime import datetime, timezone, timedelta
from app.extensions import db
from sqlalchemy.orm import relationship
from app.utils.logger import logger
from sqlalchemy.sql import func
class LoanCharge(db.Model):
__tablename__ = 'loan_charges'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
loan_id = db.Column(db.Integer, nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
code = db.Column(db.String(50), nullable=False)
amount = db.Column(db.Float, default=0.0)
percent = db.Column(db.Float, default=0.0)
description = db.Column(db.Text, nullable=True)
due = db.Column(db.Integer, nullable=False)
due_date = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
loan = relationship(
"Loan",
primaryjoin="LoanCharge.loan_id == Loan.id",
foreign_keys=[loan_id],
back_populates="loan_charges",
)
@classmethod
def create_charges_for_loan(cls, loan_id, transaction_id, charges, referenced_amount = 0.0):
"""
Create loan charges for a given loan.
Args:
loan_id (int): ID of the loan to associate charges with.
charges (list): A list of dictionaries with keys:
code (str), amount (float), percent (float), description (str), due (int)
"""
# if not charges or not isinstance(charges, list):
# raise ValueError("Charges must be a non-empty list of dictionaries")
if loan_id is None:
raise ValueError("loan_id cannot be None")
loan_charges = []
now = datetime.now(timezone.utc)
subset_keys = ['interest', 'management', 'insurance', 'vat']
for item in subset_keys:
charge = charges[item]
due_days = charge['due_days'] # getattr(charge, "due_days", 0)
amount = charge['fee'] # getattr(charge, "fee", 0.0)
percent = charge['rate'] # getattr(charge, "rate", 0.0)
code = charge['code'] # getattr(charge, "code","")
description = charge['description'] # getattr(charge, "description", "")
charge_obj = cls(
loan_id = loan_id,
transaction_id = transaction_id,
code = code,
amount = round(amount, 2),
percent = percent,
description = description,
due = due_days,
due_date = now + timedelta(days=due_days),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.session.add(charge_obj)
loan_charges.append(charge_obj)
return loan_charges
def to_dict(self):
return {
'id': self.id,
'loanId': self.loan_id,
'transactionId': self.transaction_id,
'code': self.code,
'amount': self.amount,
'percent': self.percent,
'description': self.description,
'due': self.due,
}
def __repr__(self):
return f"<LoanCharge {self.id} - Loan {self.loan_id} - {self.code}>"
+72
View File
@@ -0,0 +1,72 @@
from datetime import datetime, timezone
from app.extensions import db
from sqlalchemy.orm import relationship
from dateutil.relativedelta import relativedelta
from sqlalchemy.sql import func
class LoanRepaymentSchedule(db.Model):
__tablename__ = 'loan_repayment_schedules'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
loan_id = db.Column(db.Integer, nullable=False)
transaction_id = db.Column(db.String(50), nullable=True)
product_id = db.Column(db.String(20), nullable=True)
installment_number = db.Column(db.Integer, nullable=False)
due_date = db.Column(db.DateTime, nullable=False)
installment_amount= db.Column(db.Float, default=0.0)
total_repayment_amount = db.Column(db.Float, default=0.0)
paid = db.Column(db.Boolean, default=False)
paid_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
loan = relationship(
"Loan",
primaryjoin="LoanRepaymentSchedule.loan_id == Loan.id",
foreign_keys=[loan_id],
back_populates="loan_repayment_schedules",
)
@classmethod
def add_repayment_schedule(cls, loan, num_schedules, transaction_id):
"""
Add repayment schedules for a given loan.
"""
now = datetime.now(timezone.utc)
schedules = []
for i in range(num_schedules):
due_date = now + relativedelta(months=i + 1)
schedule = LoanRepaymentSchedule(
loan_id=loan.id,
installment_number=i + 1,
due_date=due_date,
total_repayment_amount = round(loan.repayment_amount, 2),
installment_amount=round(loan.installment_amount, 2),
product_id = loan.product_id,
transaction_id = transaction_id,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.session.add(schedule)
schedules.append(schedule)
return schedules
def to_dict(self):
return {
'id': self.id,
'loanId': self.loan_id,
'installmentNumber': self.installment_number,
'dueDate': self.due_date.isoformat(),
'principalAmount': self.principal_amount,
'interestAmount': self.interest_amount,
'totalInstallment': self.total_installment,
'paid': self.paid,
'paidAt': self.paid_at.isoformat() if self.paid_at else None
}
def __repr__(self):
return f'<LoanRepaymentSchedule Loan:{self.loan_id} Installment:{self.installment_number}>'
+81 -3
View File
@@ -1,16 +1,94 @@
from datetime import datetime, timezone
from app.extensions import db
from app.models.charge import Charge
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
class Offer(db.Model):
__tablename__ = 'offers'
id = db.Column(db.Integer, primary_key=True)
id = db.Column(db.String, primary_key=True)
product_id = db.Column(db.String, nullable=False)
min_amount = db.Column(db.Float, nullable=False)
max_amount = db.Column(db.Float, nullable=False)
tenor = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
schedule = db.Column(db.Integer, nullable=True)
interest_rate = db.Column(db.Float, default=3.0)
management_rate = db.Column(db.Float, default=1.0)
insurance_rate = db.Column(db.Float, default=1.0)
vat_rate = db.Column(db.Float, default=7.5)
list_order = db.Column(db.Integer, nullable=True)
max_daily_loans = db.Column(db.Integer, nullable=True)
max_active_loans = db.Column(db.Integer, nullable=True)
max_life_loans = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
charges = relationship(
"Charge",
primaryjoin="Offer.id == Charge.offer_id",
foreign_keys="Charge.offer_id",
back_populates="offer",
)
@classmethod
def get_all_offers(cls):
"""
Return all offers in dictionary format.
"""
offers = cls.query.all()
if not offers:
raise ValueError(f"No available offers")
return offers
@classmethod
def is_valid_offer(cls, offer_id):
offer = cls.query.filter_by(id=str(offer_id)).first()
if not offer:
return False
return offer
@classmethod
def get_offer_by_id(cls, offer_id):
"""
Return an offer by its ID.
"""
offer = cls.query.filter_by(id=str(offer_id)).first()
if not offer:
raise ValueError(f"Offer with ID {offer_id} not found")
return offer
@classmethod
def get_offer_by_product_id(cls, product_id):
"""
Return an offer by its product ID.
"""
offer = cls.query.filter_by(product_id=str(product_id)).first()
if not offer:
raise ValueError(f"Offer with Product ID {product_id} not found")
return offer
def to_dict(self):
return {
"offerId": self.id,
"productId": self.product_id,
"minAmount": self.min_amount,
"maxAmount": self.max_amount,
"tenor": self.tenor,
"interest_rate": self.interest_rate,
"management_rate": self.management_rate,
"insurance_rate": self.insurance_rate,
"vat_rate": self.vat_rate,
"maxDailyLoans": self.max_daily_loans,
"maxActiveLoans": self.max_active_loans,
"maxLifeLoans": self.max_life_loans
}
def __repr__(self):
return f'<LoanOffer {self.id}>'
+75
View File
@@ -0,0 +1,75 @@
from datetime import datetime, timezone
from app.extensions import db
from sqlalchemy.orm import relationship
from sqlalchemy.exc import IntegrityError
from uuid import uuid4
from sqlalchemy.types import JSON
from sqlalchemy.sql import func
class RACCheck(db.Model):
__tablename__ = 'rac_checks'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
transaction_id = db.Column(db.String(50), nullable=False)
customer_id = db.Column(db.String, nullable=False)
account_id = db.Column(db.String, nullable=False)
rac_response = db.Column(db.JSON, nullable=False)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@classmethod
def add_rac_check(cls, customer_id, account_id, transaction_id, data = None):
# Save the response
rac_check = cls(
customer_id = customer_id,
account_id = account_id,
transaction_id = transaction_id,
rac_response = data,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
try:
db.session.add(rac_check)
except IntegrityError as err:
raise ValueError(f"Database integrity error: {err}")
return rac_check
@classmethod
def get_all_rac_checks(cls):
"""
Return all RAC checks in dictionary format.
"""
rac_checks = cls.query.all()
if not rac_checks:
return None
return rac_checks
@classmethod
def get_rac_check(cls, customer_id, account_id):
"""
Return a RAC check by its ID.
"""
rac_check = cls.query.filter_by( customer_id = customer_id,
account_id = account_id,).first()
if not rac_check:
raise ValueError(f"RAC Check for customer not found")
return rac_check
def to_dict(self):
return {
"id": str(self.id),
"transactionId": str(self.transaction_id),
"customerId": self.customer_id,
"accountId": self.account_id,
"racResponse": self.rac_response,
"createdAt": self.created_at.isoformat(),
"updatedAt": self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<RACCheck {self.id}>'
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime, timezone
from app.api.enums.loan_status import LoanStatus
from app.extensions import db
from app.models.customer import Customer
from app.models.loan import Loan
from sqlalchemy.exc import IntegrityError
from sqlalchemy.sql import func
class Repayment(db.Model):
__tablename__ = 'repayments'
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
loan_id = db.Column(db.String(50), nullable=False)
customer_id = db.Column(db.String(50), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
transaction_id = db.Column(db.String(50), nullable=True)
repay_date = db.Column(db.DateTime, default=datetime.now(timezone.utc))
repay_result = db.Column(db.String(10), nullable=True)
repay_description = db.Column(db.String(100), nullable=True)
verify_date = db.Column(db.DateTime, default=datetime.now(timezone.utc))
verify_result = db.Column(db.String(10), nullable=True)
verify_description = db.Column(db.String(100), nullable=True)
@classmethod
def create_repayment(cls, customer_id, loan, transaction_id):
# Check that the loan is active
if loan.status not in [LoanStatus.ACTIVE, LoanStatus.START_REPAY]:
raise ValueError(f"Repayment cannot be processed. Loan status: ({loan.status})")
repayment = cls(
customer_id=customer_id,
loan_id=loan.id,
product_id=loan.product_id,
transaction_id = transaction_id,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
try:
db.session.add(repayment)
except IntegrityError as err:
raise ValueError(f"Database integrity error: {err}")
return repayment
def __repr__(self):
return f'<Repayment {self.id}>'
+24
View File
@@ -0,0 +1,24 @@
from datetime import datetime, timezone
from app.extensions import db
class RepaymentsData(db.Model):
__tablename__ = 'repayments_data'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
transaction_id = db.Column(db.String(50), nullable=False)
added_date = db.Column(db.DateTime(timezone=True), default=datetime.now(timezone.utc), nullable=False)
response_code = db.Column(db.String(10), nullable=True)
response_descr = db.Column(db.String(255), nullable=True)
def to_dict(self):
return {
"id": self.id,
"transaction_id": self.transaction_id,
"added_date": self.added_date.isoformat() if self.added_date else None,
"response_code": self.response_code,
"response_descr": self.response_descr,
}
def __repr__(self):
return f"<RepaymentsData id={self.id}, transaction_id={self.transaction_id}>"
+28 -14
View File
@@ -1,37 +1,51 @@
from datetime import datetime, timezone
from app.extensions import db
from app.models import account
from sqlalchemy.exc import IntegrityError
from sqlalchemy import and_, or_, not_
from sqlalchemy.sql import func
class Transaction(db.Model):
__tablename__ = 'transactions'
id = db.Column(db.String(50), primary_key=True)
account_id = db.Column(db.String(50), nullable=False)
id = db.Column(
db.Integer,
primary_key=True,
autoincrement=True,
)
transaction_id = db.Column(db.String(50), nullable=False)
account_id = db.Column(db.String(50), nullable=True)
customer_id = db.Column(db.String(50), nullable=True)
type = db.Column(db.String(50), nullable=False)
channel = db.Column(db.String(50), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=datetime.now(timezone.utc), onupdate=datetime.now(timezone.utc))
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def __repr__(self):
return f'<Transaction {self.id}>'
@classmethod
def create_transaction(cls, id, account_id, type, channel):
if cls.query.filter_by(id=id).first():
def create_transaction(cls, transaction_id, account_id, customer_id, type, channel):
# if cls.query.filter_by(transaction_id=transaction_id).first():
# raise ValueError("Duplicate Transaction")
if cls.query.filter( and_( cls.transaction_id ==transaction_id, cls.type==type) ).first():
raise ValueError("Duplicate Transaction")
transaction = cls(
id=id,
account_id=account_id,
type=type,
channel=channel
transaction_id = transaction_id,
customer_id = customer_id,
account_id = account_id,
type = type,
channel = channel,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
try:
db.session.add(transaction)
db.session.commit()
except IntegrityError as err:
db.session.rollback()
raise ValueError(f"Database integrity error: {err}")
return transaction
+108
View File
@@ -0,0 +1,108 @@
from datetime import datetime, timezone, timedelta
from app.api.enums.loan_status import LoanStatus
from app.extensions import db
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import logging
logger = logging.getLogger(__name__)
class TransactionOffer(db.Model):
__tablename__ = 'transaction_offers'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
customer_id = db.Column(db.String(50), nullable=False)
transaction_id = db.Column(db.String(50), nullable=False)
original_transaction = db.Column(db.String(50), nullable=True)
offer_id = db.Column(db.String(20), nullable=False)
product_id = db.Column(db.String(20), nullable=True)
min_amount = db.Column(db.Float, nullable=False)
max_amount = db.Column(db.Float, nullable=False)
eligible_amount = db.Column(db.Float, nullable=True)
tenor = db.Column(db.Integer, nullable=True) # tenor in months, typically
created_at = db.Column(db.DateTime(timezone=True), server_default=func.now())
updated_at = db.Column(db.DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
customer = relationship(
"Customer",
primaryjoin="Customer.id == TransactionOffer.customer_id",
foreign_keys=[customer_id],
back_populates="transaction_offers",
)
@classmethod
def is_valid_transaction_offer(cls, transaction_offer, customer_id, product_id):
transaction_offer = cls.query.filter_by(
id = transaction_offer,
customer_id = customer_id,
# product_id = product_id
# transaction_id = transaction_id,
).first()
if not transaction_offer:
return False
return transaction_offer
@classmethod
def create_transaction_offer(cls, customer_id, transaction_id, original_transaction, offer_id, min_amount, max_amount, eligible_amount=None, product_id=None, tenor=None):
"""
Class method to create and save a TransactionOffer.
"""
transaction_offer = cls(
customer_id=customer_id,
transaction_id=transaction_id,
original_transaction=original_transaction,
offer_id=offer_id,
min_amount=min_amount,
max_amount=max_amount,
eligible_amount=eligible_amount,
product_id=product_id,
tenor=tenor,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.session.add(transaction_offer)
db.session.flush()
return transaction_offer
@classmethod
def get_lifetime_loan_count(cls, customer_id):
"""
Returns the total number of loans ever created for a customer.
"""
return cls.query.filter_by(customer_id=customer_id).count()
@classmethod
def get_latest_transaction_offer(cls, customer_id):
"""
Returns the most recent transaction offer for the given customer based on creation time.
"""
return cls.query.filter_by(customer_id=customer_id) \
.order_by(cls.created_at.desc()) \
.first()
def to_dict(self):
return {
'id': self.id,
'customerId': self.customer_id,
'transactionId': self.transaction_id,
'offerId': self.offer_id,
'productId': self.product_id,
'minAmount': self.min_amount,
'maxAmount': self.max_amount,
'eligibleAmount': self.eligible_amount,
'tenor': self.tenor,
'createdAt': self.created_at.isoformat() if self.created_at else None,
'updatedAt': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<TransactionOffer {self.id}>'
+25 -44
View File
@@ -19,9 +19,28 @@
},
{
"url": "http://api.dev.simbrellang.net:4500"
},
{
"url": "https://api.dev.simbrellang.net"
}
],
"tags": [
{
"name": "Authorize",
"description": "This feature will be used for authorizing customers.",
"externalDocs": {
"description": "Find out more",
"url": "https://www.simbrellang.net"
}
},
{
"name": "AuthorizeRefresh",
"description": "This feature will be used for refreshing authorized customers.",
"externalDocs": {
"description": "Find out more",
"url": "https://www.simbrellang.net"
}
},
{
"name": "EligibilityCheck",
"description": "Eligibility Check Request",
@@ -61,41 +80,15 @@
"description": "Find out more",
"url": "https://www.simbrellang.net"
}
},
{
"name": "CustomerConsent",
"description": "CustomerConsent Request.",
"externalDocs": {
"description": "Find out more",
"url": "https://www.simbrellang.net"
}
},
{
"name": "NotificationCallback",
"description": "This new feature will be used for informing Simbrella about status of the transactions that FBN have processed.",
"externalDocs": {
"description": "Find out more",
"url": "https://www.simbrellang.net"
}
},
{
"name": "Authorize",
"description": "This feature will be used for authorizing customers.",
"externalDocs": {
"description": "Find out more",
"url": "https://www.simbrellang.net"
}
},
{
"name": "AuthorizeRefresh",
"description": "This feature will be used for refreshing authorized customers.",
"externalDocs": {
"description": "Find out more",
"url": "https://www.simbrellang.net"
}
}
],
"paths": {
"/Authorize": {
"$ref": "swagger/paths/Authorize.json"
},
"/AuthorizeRefresh": {
"$ref": "swagger/paths/AuthorizeRefresh.json"
},
"/EligibilityCheck": {
"$ref": "swagger/paths/EligibilityCheck.json"
},
@@ -110,18 +103,6 @@
},
"/Repayment": {
"$ref": "swagger/paths/Repayment.json"
},
"/CustomerConsent": {
"$ref": "swagger/paths/CustomerConsent.json"
},
"/NotificationCallback": {
"$ref": "swagger/paths/NotificationCallback.json"
},
"/Authorize": {
"$ref": "swagger/paths/Authorize.json"
},
"/AuthorizeRefresh": {
"$ref": "swagger/paths/AuthorizeRefresh.json"
}
},
"components": {
+1 -1
View File
@@ -43,7 +43,7 @@
}
},
"400": {
"description": "Invalid request parameters"
"description": "Invalid request"
},
"422": {
"description": "Validation exception"
+1 -1
View File
@@ -44,7 +44,7 @@
}
},
"400": {
"description": "Invalid request parameters"
"description": "Invalid request"
},
"422": {
"description": "Validation exception"
+1 -1
View File
@@ -44,7 +44,7 @@
}
},
"400": {
"description": "Invalid request parameters"
"description": "Invalid request"
},
"422": {
"description": "Validation exception"
+1 -1
View File
@@ -44,7 +44,7 @@
}
},
"400": {
"description": "Invalid request parameters"
"description": "Invalid request"
},
"422": {
"description": "Validation exception"
+1 -1
View File
@@ -44,7 +44,7 @@
}
},
"400": {
"description": "Invalid request parameters"
"description": "Invalid request"
},
"422": {
"description": "Validation exception"
+1 -1
View File
@@ -43,7 +43,7 @@
}
},
"400": {
"description": "Invalid request parameters"
"description": "Invalid request"
},
"422": {
"description": "Validation exception"
+1 -1
View File
@@ -44,7 +44,7 @@
}
},
"400": {
"description": "Invalid request parameters"
"description": "Invalid request"
},
"422": {
"description": "Validation exception"
@@ -16,6 +16,10 @@
"channel": {
"type": "string",
"example": "USSD"
},
"accountId": {
"type": "string",
"example": "ACN8263457"
}
},
"xml": {
+17 -4
View File
@@ -47,13 +47,26 @@
"productId": {
"type": "string",
"example": "101"
},
"installment": {
"type": "array",
"items": {
"type": "object",
"properties": {
"amount": {
"type": "number",
"format": "float",
"example": 10000.0
},
"repaymentDate": {
"type": "string",
"example": "2025-04-24 10:31:"
}
}
}
},
"totalDebtAmount": {
"type": "integer",
"example": 8500
}
}
}
},
"resultCode": {
"type": "string",
@@ -21,15 +21,6 @@
"type": "string",
"example": "3451342"
},
"productId": {
"type": "string",
"example": "101"
},
"lienAmount": {
"type": "number",
"format": "decimal",
"example": 400
},
"requestedAmount": {
"type": "number",
"format": "decimal",
@@ -9,6 +9,10 @@
"type": "string",
"example": "Tr201712RK9232P115"
},
"loanRef": {
"type": "string",
"example": "1620029887USSDAMPC"
},
"customerId": {
"type": "string",
"example": "CN621868"
+8 -8
View File
@@ -7,11 +7,7 @@
},
"debtId": {
"type": "string",
"example": "273194670"
},
"productId": {
"type": "string",
"example": "101"
"example": "10"
},
"transactionId": {
"type": "string",
@@ -19,11 +15,15 @@
},
"customerId": {
"type": "string",
"example": "CN621868"
"example": "CID0000025585"
},
"channel": {
"loanRef": {
"type": "string",
"example": "USSD"
"example": "Trx5847365252USSD3MPC"
},
"accountId": {
"type": "string",
"example": "ACN8263457"
}
},
"xml": {
@@ -27,6 +27,10 @@
"example": "ACN8263457"
},
"productId": {
"type": "string",
"example": "3MPC"
},
"offerId": {
"type": "string",
"example": "101"
},
+11 -2
View File
@@ -28,13 +28,17 @@
},
"productId": {
"type": "string",
"example": "2030"
"example": "3MPC"
},
"amount": {
"type": "number",
"format": "float",
"example": 10000.0
},
"dueDate": {
"type": "string",
"example": "2025-04-24 10:31:"
},
"upfrontPayment": {
"type": "number",
"format": "float",
@@ -45,6 +49,11 @@
"format": "float",
"example": 3.0
},
"interestFee": {
"type": "number",
"format": "float",
"example": 3000.00
},
"ManagementRate": {
"type": "number",
"format": "float",
@@ -75,7 +84,7 @@
"format": "float",
"example": 100.0
},
"recommendedRepaymentDates": {
"installmentRepaymentDates": {
"type": "array",
"items": {
"type": "string"
@@ -0,0 +1,35 @@
"""Migration on Tue Jun 10 08:41:00 WAT 2025
Revision ID: 0acd553309a1
Revises: 45790fd659fb
Create Date: 2025-06-10 08:41:45.222513
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0acd553309a1'
down_revision = '45790fd659fb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('repayments_data',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('transaction_id', sa.String(length=50), nullable=False),
sa.Column('added_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('response_code', sa.String(length=10), nullable=True),
sa.Column('response_descr', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('repayments_data')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Thu Apr 10 21:50:01 UTC 2025
Revision ID: 1340e7e578b9
Revises: b8f6fd76ead8
Create Date: 2025-04-10 21:50:32.113149
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1340e7e578b9'
down_revision = 'b8f6fd76ead8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.add_column(sa.Column('ref_model', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.drop_column('ref_model')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Sat May 10 09:54:34 UTC 2025
Revision ID: 173ea45db189
Revises: 3105abd795d4
Create Date: 2025-05-10 09:54:39.380499
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '173ea45db189'
down_revision = '3105abd795d4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transaction_offers', schema=None) as batch_op:
batch_op.add_column(sa.Column('original_transaction', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transaction_offers', schema=None) as batch_op:
batch_op.drop_column('original_transaction')
# ### end Alembic commands ###
@@ -0,0 +1,53 @@
"""Migration on Thu Apr 24 17:42:25 UTC 2025
Revision ID: 1b2339f43824
Revises: de9ad96ba34e
Create Date: 2025-04-24 17:43:09.589626
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b2339f43824'
down_revision = 'de9ad96ba34e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('rac_checks',
sa.Column('id', sa.String(), nullable=False),
sa.Column('transaction_id', sa.String(length=50), nullable=False),
sa.Column('customer_id', sa.String(), nullable=False),
sa.Column('account_id', sa.String(), nullable=False),
sa.Column('rac_response', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.alter_column('amount',
existing_type=sa.NUMERIC(precision=10, scale=2),
type_=sa.Float(),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('installment_amount')
batch_op.drop_column('repayment_amount')
batch_op.drop_column('upfront_fee')
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.alter_column('amount',
existing_type=sa.Float(),
type_=sa.NUMERIC(precision=10, scale=2),
existing_nullable=True)
op.drop_table('rac_checks')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Wed Apr 16 18:35:18 UTC 2025
Revision ID: 287ecb02d3d7
Revises: a4847b997191
Create Date: 2025-04-16 18:36:04.632791
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '287ecb02d3d7'
down_revision = 'a4847b997191'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.drop_column('transaction_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.add_column(sa.Column('transaction_id', sa.VARCHAR(length=50), autoincrement=False, nullable=True))
# ### end Alembic commands ###
@@ -0,0 +1,42 @@
"""Migration on Fri Apr 25 15:01:00 UTC 2025
Revision ID: 2a45dd99c9cb
Revises: 2cf0c177ca02
Create Date: 2025-04-25 15:01:51.129681
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2a45dd99c9cb'
down_revision = '2cf0c177ca02'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_repayment_schedules', schema=None) as batch_op:
batch_op.add_column(sa.Column('product_id', sa.String(length=20), nullable=True))
batch_op.add_column(sa.Column('installment_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('total_repayment_amount', sa.Float(), nullable=True))
batch_op.drop_column('principal_amount')
batch_op.drop_column('interest_amount')
batch_op.drop_column('total_installment')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_repayment_schedules', schema=None) as batch_op:
batch_op.add_column(sa.Column('total_installment', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('interest_amount', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
batch_op.add_column(sa.Column('principal_amount', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
batch_op.drop_column('total_repayment_amount')
batch_op.drop_column('installment_amount')
batch_op.drop_column('product_id')
# ### end Alembic commands ###
@@ -0,0 +1,41 @@
"""Migration on Fri Apr 25 14:02:01 UTC 2025
Revision ID: 2cf0c177ca02
Revises: 1b2339f43824
Create Date: 2025-04-25 14:02:42.244146
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2cf0c177ca02'
down_revision = '1b2339f43824'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('loan_repayment_schedules',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('loan_id', sa.Integer(), nullable=False),
sa.Column('installment_number', sa.Integer(), nullable=False),
sa.Column('due_date', sa.DateTime(), nullable=False),
sa.Column('principal_amount', sa.Float(), nullable=True),
sa.Column('interest_amount', sa.Float(), nullable=True),
sa.Column('total_installment', sa.Float(), nullable=True),
sa.Column('paid', sa.Boolean(), nullable=True),
sa.Column('paid_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('loan_repayment_schedules')
# ### end Alembic commands ###
+250
View File
@@ -0,0 +1,250 @@
"""empty message
Revision ID: 2eee4157505f
Revises: 565bc3d0ba6e
Create Date: 2025-05-16 13:24:41.914400
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '2eee4157505f'
down_revision = '565bc3d0ba6e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('accounts', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('charges', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('customers', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('loan_repayment_schedules', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('offers', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('rac_checks', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('repayments', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('transaction_offers', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True)
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(),
type_=sa.DateTime(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
with op.batch_alter_table('transaction_offers', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('repayments', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('rac_checks', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('offers', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('loan_repayment_schedules', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('customers', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('charges', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
with op.batch_alter_table('accounts', schema=None) as batch_op:
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
batch_op.alter_column('created_at',
existing_type=sa.DateTime(timezone=True),
type_=postgresql.TIMESTAMP(),
existing_nullable=True)
# ### end Alembic commands ###
+52
View File
@@ -0,0 +1,52 @@
"""empty message
Revision ID: 3105abd795d4
Revises: 95a52be203c4
Create Date: 2025-05-07 11:44:18.483694
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3105abd795d4'
down_revision = '95a52be203c4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('rac_checks', schema=None) as batch_op:
# Step 1: Drop the default value
batch_op.alter_column('id',
server_default=None,
existing_type=sa.VARCHAR(),
existing_nullable=False
)
with op.batch_alter_table('rac_checks', schema=None) as batch_op:
# Step 2: Change the column type
batch_op.alter_column('id',
existing_type=sa.VARCHAR(),
type_=sa.Integer(),
existing_nullable=False,
autoincrement=True,
postgresql_using='id::integer'
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('rac_checks', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.Integer(),
type_=sa.VARCHAR(),
existing_nullable=False,
autoincrement=True,
existing_server_default=sa.text("''::character varying"))
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration for mloan table
Revision ID: 38acee611d55
Revises: f1e83a993034
Create Date: 2025-04-30 09:55:30.552838
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '38acee611d55'
down_revision = 'f1e83a993034'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('tenor', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('tenor')
# ### end Alembic commands ###
+54
View File
@@ -0,0 +1,54 @@
"""empty message
Revision ID: 45790fd659fb
Revises: b3a5e10bc77e
Create Date: 2025-06-04 12:37:48.180736
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '45790fd659fb'
down_revision = 'b3a5e10bc77e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('disburse_result', sa.String(length=10), nullable=True))
batch_op.add_column(sa.Column('disburse_description', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('verify_result', sa.String(length=10), nullable=True))
batch_op.add_column(sa.Column('verify_description', sa.String(length=100), nullable=True))
with op.batch_alter_table('repayments', schema=None) as batch_op:
batch_op.add_column(sa.Column('repay_date', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('repay_result', sa.String(length=10), nullable=True))
batch_op.add_column(sa.Column('repay_description', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('verify_date', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('verify_result', sa.String(length=10), nullable=True))
batch_op.add_column(sa.Column('verify_description', sa.String(length=100), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('repayments', schema=None) as batch_op:
batch_op.drop_column('verify_description')
batch_op.drop_column('verify_result')
batch_op.drop_column('verify_date')
batch_op.drop_column('repay_description')
batch_op.drop_column('repay_result')
batch_op.drop_column('repay_date')
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('verify_description')
batch_op.drop_column('verify_result')
batch_op.drop_column('disburse_description')
batch_op.drop_column('disburse_result')
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""Migration on Sat May 10 12:54:52 UTC 2025
Revision ID: 565bc3d0ba6e
Revises: 173ea45db189
Create Date: 2025-05-10 12:54:56.683215
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '565bc3d0ba6e'
down_revision = '173ea45db189'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('disburse_date', sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column('disburse_verify', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('disburse_verify')
batch_op.drop_column('disburse_date')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Fri Apr 11 14:15:19 UTC 2025
Revision ID: 610b7e9d15a6
Revises: 9bb0367eb486
Create Date: 2025-04-11 14:16:12.533227
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '610b7e9d15a6'
down_revision = '9bb0367eb486'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('transaction_id', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('transaction_id')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Mon Apr 14 15:15:05 UTC 2025
Revision ID: 783a023a477f
Revises: f6cd1bfc8832
Create Date: 2025-04-14 15:15:36.991148
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '783a023a477f'
down_revision = 'f6cd1bfc8832'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.add_column(sa.Column('customer_id', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.drop_column('customer_id')
# ### end Alembic commands ###
+41
View File
@@ -0,0 +1,41 @@
"""empty message
Revision ID: 86e701febdda
Revises: eb99c7fb9e09
Create Date: 2025-04-29 07:59:33.305967
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '86e701febdda'
down_revision = 'eb99c7fb9e09'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('transaction_offers',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('customer_id', sa.String(length=50), nullable=False),
sa.Column('transaction_id', sa.String(length=50), nullable=False),
sa.Column('offer_id', sa.String(length=20), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('min_amount', sa.Float(), nullable=False),
sa.Column('max_amount', sa.Float(), nullable=False),
sa.Column('eligible_amount', sa.Float(), nullable=True),
sa.Column('tenor', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('transaction_offers')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""Migration on Sat Apr 26 12:50:46 UTC 2025
Revision ID: 89759cebb9c6
Revises: 2a45dd99c9cb
Create Date: 2025-04-26 12:50:49.771355
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '89759cebb9c6'
down_revision = '2a45dd99c9cb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('offers', schema=None) as batch_op:
batch_op.add_column(sa.Column('interest_rate', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('management_rate', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('insurance_rate', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('vat_rate', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('offers', schema=None) as batch_op:
batch_op.drop_column('vat_rate')
batch_op.drop_column('insurance_rate')
batch_op.drop_column('management_rate')
batch_op.drop_column('interest_rate')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Sat May 3 21:53:29 UTC 2025
Revision ID: 95a52be203c4
Revises: 38acee611d55
Create Date: 2025-05-03 21:53:32.154029
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '95a52be203c4'
down_revision = '38acee611d55'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('eligible_amount', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('eligible_amount')
# ### end Alembic commands ###
@@ -0,0 +1,40 @@
"""Migration on Fri Apr 11 12:48:01 UTC 2025
Revision ID: 9bb0367eb486
Revises: fd447d78b161
Create Date: 2025-04-11 12:48:36.145311
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9bb0367eb486'
down_revision = 'fd447d78b161'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('product_id', sa.String(length=20), nullable=True))
batch_op.add_column(sa.Column('current_loan_amount', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('default_penalty_fee', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('continuous_fee', sa.Float(), nullable=True))
batch_op.add_column(sa.Column('due_date', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('due_date')
batch_op.drop_column('continuous_fee')
batch_op.drop_column('default_penalty_fee')
batch_op.drop_column('current_loan_amount')
batch_op.drop_column('product_id')
# ### end Alembic commands ###
@@ -0,0 +1,57 @@
"""Migration on Wed Apr 16 17:42:49 UTC 2025
Revision ID: a4847b997191
Revises: 783a023a477f
Create Date: 2025-04-16 17:43:22.509659
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a4847b997191'
down_revision = '783a023a477f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('loan_charges',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('loan_id', sa.Integer(), nullable=False),
sa.Column('transaction_id', sa.String(length=50), nullable=True),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('amount', sa.Float(), nullable=True),
sa.Column('percent', sa.Float(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('due', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('offers',
sa.Column('id', sa.String(), nullable=False),
sa.Column('product_id', sa.String(), nullable=False),
sa.Column('min_amount', sa.Float(), nullable=False),
sa.Column('max_amount', sa.Float(), nullable=False),
sa.Column('tenor', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('product_id', sa.String(length=20), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('product_id')
op.drop_table('offers')
op.drop_table('loan_charges')
# ### end Alembic commands ###
+32
View File
@@ -0,0 +1,32 @@
"""empty message
Revision ID: b3a5e10bc77e
Revises: e8dd9b841ad7
Create Date: 2025-05-27 01:52:48.538333
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b3a5e10bc77e'
down_revision = 'e8dd9b841ad7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('reference', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('reference')
# ### end Alembic commands ###
@@ -0,0 +1,86 @@
"""Migration on Thu Apr 10 16:21:45 UTC 2025
Revision ID: b8f6fd76ead8
Revises:
Create Date: 2025-04-10 16:22:15.946157
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'b8f6fd76ead8'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('repayments',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('loan_id', sa.String(length=50), nullable=False),
sa.Column('customer_id', sa.String(length=50), nullable=False),
sa.Column('product_id', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.VARCHAR(length=50),
type_=sa.Integer(),
existing_nullable=False,
autoincrement=True,
existing_server_default=sa.text("nextval('loan_id_seq'::regclass)"))
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.alter_column('channel',
existing_type=sa.VARCHAR(length=8),
type_=sa.String(length=50),
existing_nullable=False)
batch_op.alter_column('created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
batch_op.alter_column('updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
batch_op.drop_constraint('transactions_id_key', type_='unique')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.create_unique_constraint('transactions_id_key', ['id'])
batch_op.alter_column('updated_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
batch_op.alter_column('created_at',
existing_type=sa.DateTime(),
type_=postgresql.TIMESTAMP(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
batch_op.alter_column('channel',
existing_type=sa.String(length=50),
type_=sa.VARCHAR(length=8),
existing_nullable=False)
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.alter_column('id',
existing_type=sa.Integer(),
type_=sa.VARCHAR(length=50),
existing_nullable=False,
autoincrement=True,
existing_server_default=sa.text("nextval('loan_id_seq'::regclass)"))
op.drop_table('repayments')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""Migration on Thu Apr 17 14:15:36 UTC 2025
Revision ID: de9ad96ba34e
Revises: ec8d97f9b584
Create Date: 2025-04-17 14:16:16.537466
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'de9ad96ba34e'
down_revision = 'ec8d97f9b584'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('charges',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('offer_id', sa.String(length=50), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('percent', sa.Float(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('due', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('charges')
# ### end Alembic commands ###
+36
View File
@@ -0,0 +1,36 @@
"""empty message
Revision ID: e8dd9b841ad7
Revises: 2eee4157505f
Create Date: 2025-05-19 11:46:19.204637
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e8dd9b841ad7'
down_revision = '2eee4157505f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('offers', schema=None) as batch_op:
batch_op.add_column(sa.Column('max_daily_loans', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('max_active_loans', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('max_life_loans', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('offers', schema=None) as batch_op:
batch_op.drop_column('max_life_loans')
batch_op.drop_column('max_active_loans')
batch_op.drop_column('max_daily_loans')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Sat Apr 26 19:02:17 UTC 2025
Revision ID: eb99c7fb9e09
Revises: 89759cebb9c6
Create Date: 2025-04-26 19:02:20.443678
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'eb99c7fb9e09'
down_revision = '89759cebb9c6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('original_transaction', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.drop_column('original_transaction')
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""Migration on Thu Apr 17 10:40:05 UTC 2025
Revision ID: ec8d97f9b584
Revises: 287ecb02d3d7
Create Date: 2025-04-17 10:40:34.751272
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ec8d97f9b584'
down_revision = '287ecb02d3d7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.add_column(sa.Column('transaction_id', sa.String(length=50), nullable=True))
batch_op.add_column(sa.Column('due_date', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_charges', schema=None) as batch_op:
batch_op.drop_column('due_date')
batch_op.drop_column('transaction_id')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""Migration on Tue Apr 29 20:43:35 UTC 2025
Revision ID: f1e83a993034
Revises: 86e701febdda
Create Date: 2025-04-29 20:43:38.595543
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f1e83a993034'
down_revision = '86e701febdda'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_repayment_schedules', schema=None) as batch_op:
batch_op.add_column(sa.Column('transaction_id', sa.String(length=50), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loan_repayment_schedules', schema=None) as batch_op:
batch_op.drop_column('transaction_id')
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""Migration on Fri Apr 11 14:34:36 UTC 2025
Revision ID: f6cd1bfc8832
Revises: 610b7e9d15a6
Create Date: 2025-04-11 14:35:07.093967
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f6cd1bfc8832'
down_revision = '610b7e9d15a6'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('collection_type', sa.String(length=20), nullable=True))
batch_op.drop_column('product_id')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('loans', schema=None) as batch_op:
batch_op.add_column(sa.Column('product_id', sa.VARCHAR(length=20), autoincrement=False, nullable=True))
batch_op.drop_column('collection_type')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""Migration on Fri Apr 11 12:02:45 UTC 2025
Revision ID: fd447d78b161
Revises: 1340e7e578b9
Create Date: 2025-04-11 12:03:28.346671
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fd447d78b161'
down_revision = '1340e7e578b9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.alter_column('account_id',
existing_type=sa.VARCHAR(length=50),
nullable=True)
batch_op.drop_column('ref_model')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('transactions', schema=None) as batch_op:
batch_op.add_column(sa.Column('ref_model', sa.VARCHAR(length=50), autoincrement=False, nullable=True))
batch_op.alter_column('account_id',
existing_type=sa.VARCHAR(length=50),
nullable=False)
# ### end Alembic commands ###
+10 -1
View File
@@ -25,8 +25,17 @@ flask-swagger-ui
python-dotenv
# Requests
requests
httpx
# JWT
flask-jwt-extended
# Kafka
confluent-kafka==1.9.2
python-dateutil
+3 -2
View File
@@ -1,7 +1,8 @@
#!/bin/sh
echo "Running DB migrations..."
flask db upgrade
# echo "Running DB migrations..."
# flask db migrate -m "Migration on $(date)"
# flask db upgrade
echo "Starting Gunicorn server..."
exec gunicorn -w 4 -b 0.0.0.0:5000 wsgi:wsgi_app
+651
View File
@@ -0,0 +1,651 @@
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Simbrella FirstAdvance API Test">
<boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables">
<collectionProp name="Arguments.arguments">
<elementProp name="baseUrl" elementType="Argument">
<stringProp name="Argument.name">baseUrl</stringProp>
<stringProp name="Argument.value">http://localhost:4500</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="username" elementType="Argument">
<stringProp name="Argument.name">username</stringProp>
<stringProp name="Argument.value">user</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="password" elementType="Argument">
<stringProp name="Argument.name">password</stringProp>
<stringProp name="Argument.value">password</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
</TestPlan>
<hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Authorizaton Thread Group">
<intProp name="ThreadGroup.num_threads">1</intProp>
<intProp name="ThreadGroup.ramp_time">1</intProp>
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
<stringProp name="ThreadGroup.on_sample_error">stopthread</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller">
<stringProp name="LoopController.loops">1</stringProp>
<boolProp name="LoopController.continue_forever">false</boolProp>
</elementProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="1. Authorize" enabled="true">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">4500</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/Authorize</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;username&quot;:&quot;${username}&quot;,&#xd;
&quot;password&quot;:&quot;${password}&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Auth Token" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">access_token</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.data.access_token</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
<stringProp name="JSONPostProcessor.scope">variable</stringProp>
<stringProp name="Scope.variable"></stringProp>
<stringProp name="Sample.scope">all</stringProp>
</JSONPostProcessor>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Auth Refresh Token" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">refresh_token</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.data.refresh_token</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
<stringProp name="JSONPostProcessor.scope">variable</stringProp>
<stringProp name="Scope.variable"></stringProp>
<stringProp name="Sample.scope">all</stringProp>
</JSONPostProcessor>
<hashTree/>
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="JSR223 PostProcessor" enabled="true">
<stringProp name="cacheKey">true</stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="script">props.put(&quot;GLOBAL_ACCESS_TOKEN&quot;, vars.get(&quot;access_token&quot;));
props.put(&quot;GLOBAL_REFRESH_TOKEN&quot;, vars.get(&quot;refresh_token&quot;));</stringProp>
<stringProp name="scriptLanguage">groovy</stringProp>
</JSR223PostProcessor>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true">
<collectionProp name="Asserion.test_strings"/>
<collectionProp name="Asserter.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
<stringProp name="Assertion.custom_message"></stringProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<DebugSampler guiclass="TestBeanGUI" testclass="DebugSampler" testname="Debug Sampler" enabled="true">
<boolProp name="displayJMeterProperties">false</boolProp>
<boolProp name="displayJMeterVariables">true</boolProp>
<boolProp name="displaySystemProperties">false</boolProp>
</DebugSampler>
<hashTree/>
<ResultCollector guiclass="ViewResultsFullVisualizer" testclass="ResultCollector" testname="View Results Tree" enabled="true">
<boolProp name="ResultCollector.error_logging">false</boolProp>
<objProp>
<name>saveConfig</name>
<value class="SampleSaveConfiguration">
<time>true</time>
<latency>true</latency>
<timestamp>true</timestamp>
<success>true</success>
<label>true</label>
<code>true</code>
<message>true</message>
<threadName>true</threadName>
<dataType>true</dataType>
<encoding>false</encoding>
<assertions>true</assertions>
<subresults>true</subresults>
<responseData>false</responseData>
<samplerData>false</samplerData>
<xml>false</xml>
<fieldNames>true</fieldNames>
<responseHeaders>false</responseHeaders>
<requestHeaders>false</requestHeaders>
<responseDataOnError>false</responseDataOnError>
<saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
<assertionsResultsToSave>0</assertionsResultsToSave>
<bytes>true</bytes>
<sentBytes>true</sentBytes>
<url>true</url>
<threadCounts>true</threadCounts>
<idleTime>true</idleTime>
<connectTime>true</connectTime>
</value>
</objProp>
<stringProp name="filename"></stringProp>
</ResultCollector>
<hashTree/>
</hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="API Test Thread Group">
<intProp name="ThreadGroup.num_threads">1</intProp>
<intProp name="ThreadGroup.ramp_time">1</intProp>
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller">
<stringProp name="LoopController.loops">1</stringProp>
<boolProp name="LoopController.continue_forever">false</boolProp>
</elementProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="2. Authorize Refresh" enabled="true">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">4500</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/AuthorizeRefresh</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&#xd;
}&#xd;
</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${__P(GLOBAL_REFRESH_TOKEN)}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true">
<collectionProp name="Asserion.test_strings"/>
<collectionProp name="Asserter.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
<stringProp name="Assertion.custom_message"></stringProp>
</ResponseAssertion>
<hashTree/>
<JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor" testname="JSR223 PreProcessor" enabled="true">
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="cacheKey">true</stringProp>
<stringProp name="script">// Generate random IDs and store them in JMeter variables
def transactionId = &quot;TR&quot; + org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric(12)
def customerId = &quot;CN&quot; + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999)
def accountId = &quot;ACN&quot; + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999)
def msisdn = &quot;809&quot; + org.apache.commons.lang3.RandomUtils.nextInt(1000000, 9999999)
// Generate requestId: current timestamp + 6-digit random number
def timestamp = new Date().format(&quot;yyyyMMddHHmmssSSS&quot;) // e.g., 20250414161243123
def randomSuffix = org.apache.commons.lang3.RandomStringUtils.randomNumeric(6)
def requestId = timestamp + randomSuffix
vars.put(&quot;transactionId&quot;, transactionId)
vars.put(&quot;customerId&quot;, customerId.toString())
vars.put(&quot;accountId&quot;, accountId.toString())
vars.put(&quot;msisdn&quot;, msisdn.toString())
vars.put(&quot;requestId&quot;, requestId)
</stringProp>
</JSR223PreProcessor>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="3. Eligibility Check">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">4500</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/EligibilityCheck</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;transactionId&quot;:&quot;${transactionId}&quot;,&#xd;
&quot;countryCode&quot;:&quot;NGR&quot;,&#xd;
&quot;customerId&quot;:&quot;${customerId}&quot;,&#xd;
&quot;msisdn&quot;:&quot;${msisdn}&quot;,&#xd;
&quot;channel&quot;:&quot;100&quot;,&#xd;
&quot;accountId&quot;:&quot;${accountId}&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${__P(GLOBAL_ACCESS_TOKEN)}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Product ID">
<stringProp name="JSONPostProcessor.referenceNames">productId</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.eligibleOffers[0].productId</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">0</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
<stringProp name="JSONPostProcessor.scope">variable</stringProp>
</JSONPostProcessor>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true">
<collectionProp name="Asserion.test_strings"/>
<collectionProp name="Asserter.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
<stringProp name="Assertion.custom_message"></stringProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="4. Select Offer">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">4500</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/SelectOffer</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;requestId&quot;: &quot;${requestId}&quot;,&#xd;
&quot;transactionId&quot;: &quot;${transactionId}&quot;,&#xd;
&quot;customerId&quot;:&quot;${customerId}&quot;,&#xd;
&quot;msisdn&quot;: &quot;${msisdn}&quot;,&#xd;
&quot;requestedAmount&quot;: ${__Random(500000,1000000,)}.00,&#xd;
&quot;accountId&quot;:&quot;${accountId}&quot;,&#xd;
&quot;productId&quot;: &quot;${productId}&quot;,&#xd;
&quot;channel&quot;: &quot;100&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${__P(GLOBAL_ACCESS_TOKEN)}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Offer ID">
<stringProp name="JSONPostProcessor.referenceNames">offerId</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.loan[0].offerId</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">0</stringProp>
<stringProp name="JSONPostProcessor.scope">variable</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
<stringProp name="Sample.scope">all</stringProp>
</JSONPostProcessor>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Requested Amount" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">amount</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.loan[0].amount</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">0</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">800</stringProp>
<stringProp name="JSONPostProcessor.scope">variable</stringProp>
<stringProp name="Sample.scope">all</stringProp>
</JSONPostProcessor>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true">
<collectionProp name="Asserion.test_strings"/>
<collectionProp name="Asserter.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
<stringProp name="Assertion.custom_message"></stringProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="5. Provide Loan">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">4500</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/ProvideLoan</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;requestId&quot;:&quot;${requestId}&quot;,&#xd;
&quot;transactionId&quot;:&quot;${transactionId}&quot;,&#xd;
&quot;customerId&quot;:&quot;${customerId}&quot;,&#xd;
&quot;accountId&quot;:&quot;${accountId}&quot;,&#xd;
&quot;msisdn&quot;:&quot;${msisdn}&quot;,&#xd;
&quot;requestedAmount&quot;:${amount},&#xd;
&quot;collectionType&quot;:1,&#xd;
&quot;offerId&quot;:&quot;${offerId}&quot;,&#xd;
&quot;channel&quot;:&quot;100&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${__P(GLOBAL_ACCESS_TOKEN)}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true">
<collectionProp name="Asserion.test_strings"/>
<collectionProp name="Asserter.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
<stringProp name="Assertion.custom_message"></stringProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="6. Loan Status" enabled="true">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">4500</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/LoanStatus</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;transactionId&quot;:&quot;${transactionId}&quot;,&#xd;
&quot;customerId&quot;:&quot;${customerId}&quot;,&#xd;
&quot;msisdn&quot;:&quot;${msisdn}&quot;,&#xd;
&quot;channel&quot;:&quot;100&quot;,&#xd;
&quot;accountId&quot;:&quot;${accountId}&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${__P(GLOBAL_ACCESS_TOKEN)}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Debt Id" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">debtId</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.loans[0].debtId</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">0</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">800</stringProp>
<stringProp name="JSONPostProcessor.scope">variable</stringProp>
<stringProp name="Sample.scope">all</stringProp>
</JSONPostProcessor>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true">
<collectionProp name="Asserion.test_strings"/>
<collectionProp name="Asserter.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
<stringProp name="Assertion.custom_message"></stringProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="7. Repayment" enabled="true">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">4500</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/Repayment</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;debtId&quot;:&quot;${debtId}&quot;,&#xd;
&quot;transactionId&quot;:&quot;${transactionId}&quot;,&#xd;
&quot;customerId&quot;:&quot;${customerId}&quot;,&#xd;
&quot;msisdn&quot;:&quot;${msisdn}&quot;,&#xd;
&quot;channel&quot;:&quot;100&quot;,&#xd;
&quot;accountId&quot;:&quot;${accountId}&quot;,&#xd;
&quot;productId&quot;: &quot;${productId}&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${__P(GLOBAL_ACCESS_TOKEN)}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Response Assertion" enabled="true">
<collectionProp name="Asserion.test_strings"/>
<collectionProp name="Asserter.test_strings">
<stringProp name="49586">200</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
<stringProp name="Assertion.custom_message"></stringProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<ResultCollector guiclass="ViewResultsFullVisualizer" testclass="ResultCollector" testname="View Results Tree" enabled="true">
<boolProp name="ResultCollector.error_logging">false</boolProp>
<objProp>
<name>saveConfig</name>
<value class="SampleSaveConfiguration">
<time>true</time>
<latency>true</latency>
<timestamp>true</timestamp>
<success>true</success>
<label>true</label>
<code>true</code>
<message>true</message>
<threadName>true</threadName>
<dataType>true</dataType>
<encoding>false</encoding>
<assertions>true</assertions>
<subresults>true</subresults>
<responseData>false</responseData>
<samplerData>false</samplerData>
<xml>false</xml>
<fieldNames>true</fieldNames>
<responseHeaders>false</responseHeaders>
<requestHeaders>false</requestHeaders>
<responseDataOnError>false</responseDataOnError>
<saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
<assertionsResultsToSave>0</assertionsResultsToSave>
<bytes>true</bytes>
<sentBytes>true</sentBytes>
<url>true</url>
<threadCounts>true</threadCounts>
<idleTime>true</idleTime>
<connectTime>true</connectTime>
</value>
</objProp>
<stringProp name="filename"></stringProp>
</ResultCollector>
<hashTree/>
<DebugSampler guiclass="TestBeanGUI" testclass="DebugSampler" testname="Debug Sampler" enabled="true">
<boolProp name="displayJMeterProperties">false</boolProp>
<boolProp name="displayJMeterVariables">true</boolProp>
<boolProp name="displaySystemProperties">false</boolProp>
</DebugSampler>
<hashTree/>
</hashTree>
<ResultCollector guiclass="ViewResultsFullVisualizer" testclass="ResultCollector" testname="View Results Tree">
<boolProp name="ResultCollector.error_logging">false</boolProp>
<objProp>
<name>saveConfig</name>
<value class="SampleSaveConfiguration">
<time>true</time>
<latency>true</latency>
<timestamp>true</timestamp>
<success>true</success>
<label>true</label>
<code>true</code>
<message>true</message>
<threadName>true</threadName>
<dataType>true</dataType>
<encoding>false</encoding>
<assertions>true</assertions>
<subresults>true</subresults>
<responseData>false</responseData>
<samplerData>false</samplerData>
<xml>false</xml>
<fieldNames>true</fieldNames>
<responseHeaders>false</responseHeaders>
<requestHeaders>false</requestHeaders>
<responseDataOnError>false</responseDataOnError>
<saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
<assertionsResultsToSave>0</assertionsResultsToSave>
<bytes>true</bytes>
<sentBytes>true</sentBytes>
<url>true</url>
<threadCounts>true</threadCounts>
<idleTime>true</idleTime>
<connectTime>true</connectTime>
</value>
</objProp>
<stringProp name="filename"></stringProp>
</ResultCollector>
<hashTree/>
</hashTree>
</hashTree>
</jmeterTestPlan>